refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,51 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary><c>moshiro.event</c>:Agent 报告的事件(Probe 连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。</summary>
|
||||
public sealed class AgentEventService
|
||||
{
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time, NodeInfoService nodes)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_time = time;
|
||||
_nodes = nodes;
|
||||
}
|
||||
|
||||
public async ValueTask OnEventAsync(NodeId agent, AgentEventParams e, CancellationToken ct)
|
||||
{
|
||||
if (!AgentEventKinds.All.Contains(e.Kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 节点上报的时间可能有偏差:限制在合理范围内,避免污染事件时间线
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long at = Math.Abs(e.AtUtcMs - now) > 24L * 3600 * 1000 ? now : e.AtUtcMs;
|
||||
|
||||
AgentRuntimeState state = _runtime.Get(agent);
|
||||
switch (e.Kind)
|
||||
{
|
||||
case AgentEventKinds.ProbeConnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
_nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote);
|
||||
break;
|
||||
case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.RemoveProbe(e.Detail);
|
||||
break;
|
||||
case AgentEventKinds.EndpointChanged:
|
||||
state.CountEndpointChange();
|
||||
break;
|
||||
}
|
||||
|
||||
string? detail = e.Detail is { Length: > 512 } ? e.Detail[..512] : e.Detail;
|
||||
await _events.AddAsync(agent, e.Kind, at, detail, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs);
|
||||
|
||||
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
|
||||
public sealed class AgentRuntimeState
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private int _endpointChanges;
|
||||
private IReadOnlyList<ConnectedProbeInfo> _probes = [];
|
||||
|
||||
/// <summary>当前连着这个 Agent 的 Probe(每个 Probe 一项)。</summary>
|
||||
public IReadOnlyList<ConnectedProbeInfo> Probes => Volatile.Read(ref _probes);
|
||||
|
||||
public bool ProbeConnected => Probes.Count > 0;
|
||||
|
||||
public bool IsConnected(string probeId) => Probes.Any(p => p.Id == probeId);
|
||||
|
||||
/// <summary>事件 <c>probe_connected</c>:事件里带着 Probe 的版本和地址;心跳里的完整快照随后会再校正一次。</summary>
|
||||
public void AddProbe(ConnectedProbeInfo info)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id), info]);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveProbe(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>心跳里的完整快照:覆盖事件推算出的结果。</summary>
|
||||
public void ReplaceProbes(IEnumerable<ConnectedProbeInfo> probes)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. probes.OrderBy(p => p.SinceMs).ThenBy(p => p.Id, StringComparer.Ordinal)]);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearProbes() => ReplaceProbes([]);
|
||||
|
||||
/// <summary>最新的主机资源占用快照,以及收到它的时间。</summary>
|
||||
public HostSnapshot? Host { get; set; }
|
||||
|
||||
public long HostReceivedAtMs { get; set; }
|
||||
|
||||
public AgentHeartbeatExtra? LastHeartbeat { get; set; }
|
||||
|
||||
public long LastHeartbeatAtMs { get; set; }
|
||||
|
||||
public int EndpointChanges => Volatile.Read(ref _endpointChanges);
|
||||
|
||||
public void CountEndpointChange() => Interlocked.Increment(ref _endpointChanges);
|
||||
}
|
||||
|
||||
public sealed class AgentRuntime
|
||||
{
|
||||
private readonly ConcurrentDictionary<NodeId, AgentRuntimeState> _states = new();
|
||||
|
||||
public AgentRuntimeState Get(NodeId agent) => _states.GetOrAdd(agent, _ => new AgentRuntimeState());
|
||||
|
||||
public bool TryGet(NodeId agent, out AgentRuntimeState state) => _states.TryGetValue(agent, out state!);
|
||||
|
||||
public void Remove(NodeId agent) => _states.TryRemove(agent, out _);
|
||||
|
||||
/// <summary>某个 Probe 现在连着哪些 Agent。</summary>
|
||||
public IReadOnlyList<(NodeId Agent, ConnectedProbeInfo Probe)> ConnectionsOf(string probeId)
|
||||
{
|
||||
var result = new List<(NodeId, ConnectedProbeInfo)>();
|
||||
foreach ((NodeId agent, AgentRuntimeState state) in _states)
|
||||
{
|
||||
foreach (ConnectedProbeInfo probe in state.Probes)
|
||||
{
|
||||
if (probe.Id == probeId)
|
||||
{
|
||||
result.Add((agent, probe));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Agent 断开时,它对 Probe 会话的认知也随之失效。</summary>
|
||||
public void MarkOffline(NodeId agent)
|
||||
{
|
||||
if (_states.TryGetValue(agent, out AgentRuntimeState? state))
|
||||
{
|
||||
state.ClearProbes();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedProbeInfo> Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null)
|
||||
{
|
||||
/// <summary>至少有一个 Probe 连着。</summary>
|
||||
public bool ProbeConnected => Probes.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>Agent 的管理:列表、修改、审批、启停、接管、删除,以及 Probe 需要的 Agent 列表。</summary>
|
||||
public sealed partial class AgentService
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TaskPlanner _planner;
|
||||
private readonly ISettingsStore _settings;
|
||||
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links)
|
||||
{
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
_planner = planner;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
return [.. agents.Select(View)];
|
||||
}
|
||||
|
||||
public async ValueTask<AgentView?> GetAsync(NodeId id, CancellationToken ct) =>
|
||||
await _agents.GetAsync(id, ct) is { } agent ? View(agent) : null;
|
||||
|
||||
public AgentView View(AgentRecord agent)
|
||||
{
|
||||
_runtime.TryGet(agent.Id, out AgentRuntimeState? state);
|
||||
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<AgentView>> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (patch.Name is not null && !NamePattern().IsMatch(patch.Name))
|
||||
{
|
||||
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
|
||||
}
|
||||
|
||||
if (patch.PublicHost is not null && !HostPattern().IsMatch(patch.PublicHost))
|
||||
{
|
||||
errors["public_host"] = "must be a host name or IP address";
|
||||
}
|
||||
|
||||
if (patch.Labels is not null && (patch.Labels.Count > 16 || patch.Labels.Any(l => l.Length is 0 or > 32)))
|
||||
{
|
||||
errors["labels"] = "at most 16 labels of 1-32 characters";
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<AgentView>(errors);
|
||||
}
|
||||
|
||||
Result<AgentRecord> result = await _agents.PatchAsync(id, patch, expectedRowVersion, ct);
|
||||
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
|
||||
{
|
||||
return Result<AgentView>.Fail(error);
|
||||
}
|
||||
|
||||
if (patch.Name is not null || patch.PublicHost is not null)
|
||||
{
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
|
||||
return Result<AgentView>.Ok(View(agent));
|
||||
}
|
||||
|
||||
public ValueTask<Result<AgentView>> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
|
||||
|
||||
public ValueTask<Result<AgentView>> EnableAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
|
||||
|
||||
public async ValueTask<Result<AgentView>> DisableAsync(NodeId id, CancellationToken ct)
|
||||
{
|
||||
Result<AgentView> result = await ChangeStatusAsync(id, NodeStatus.Disabled, ct);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await _gateway.DisconnectAsync(id, "disabled", ct);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<AgentView>> TakeoverAsync(NodeId pendingId, CancellationToken ct)
|
||||
{
|
||||
Result<AgentRecord> result = await _agents.TakeoverAsync(pendingId, ct);
|
||||
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
|
||||
{
|
||||
return Result<AgentView>.Fail(error);
|
||||
}
|
||||
|
||||
// 公钥换了,旧连接使用的是旧密钥
|
||||
await _gateway.DisconnectAsync(agent.Id, "key replaced", ct);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
return Result<AgentView>.Ok(View(agent));
|
||||
}
|
||||
|
||||
public async ValueTask DeleteAsync(NodeId id, CancellationToken ct)
|
||||
{
|
||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||
await _agents.DeleteAsync(id, ct);
|
||||
await _nodes.ForgetAsync(id.Value, ct);
|
||||
await _links.ForgetNodeAsync(id.Value, ct);
|
||||
_runtime.Remove(id);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>这个 Probe 要连接的 Agent 列表(已激活、有公开地址,并且线路是开着的)。版本号在列表变化时递增。</summary>
|
||||
public async ValueTask<ProbeAgentList> ProbeAgentsAsync(string probeId, CancellationToken ct)
|
||||
{
|
||||
LinkSnapshot links = await _links.SnapshotAsync(ct);
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0;
|
||||
var result = new ProbeAgentList { Version = version };
|
||||
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && links.IsLinked(probeId, a.Id.Value)))
|
||||
{
|
||||
result.Agents.Add(new AgentEndpoint
|
||||
{
|
||||
AgentId = agent.Id,
|
||||
Name = agent.Name,
|
||||
Host = agent.PublicHost,
|
||||
TcpPort = agent.ProbeTcpPort,
|
||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
||||
Spki = Convert.ToBase64String(agent.TlsSpki),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Agent 集合变了:递增列表版本,重算各 Agent 的期望状态,并把新的 Agent 列表推给 Probe(都是期望状态,一起通知)。</summary>
|
||||
public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct)
|
||||
{
|
||||
await _planner.BumpProbeAgentsVersionAsync(ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
private async ValueTask<Result<AgentView>> ChangeStatusAsync(NodeId id, NodeStatus status, CancellationToken ct)
|
||||
{
|
||||
Result<AgentRecord> result = await _agents.SetStatusAsync(id, status, ct);
|
||||
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
|
||||
{
|
||||
return Result<AgentView>.Fail(error);
|
||||
}
|
||||
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
_gateway.NotifyTaskSetChanged(id);
|
||||
return Result<AgentView>.Ok(View(agent));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||
private static partial Regex NamePattern();
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9.:\-\[\]]{1,253}$")]
|
||||
private static partial Regex HostPattern();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// <c>moshiro.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。
|
||||
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。
|
||||
/// </summary>
|
||||
public sealed class HostStatusService
|
||||
{
|
||||
private static readonly TimeSpan s_stale = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IHostStore _store;
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<NodeId, long> _lastSavedMinute = [];
|
||||
private readonly object _gate = new();
|
||||
|
||||
public HostStatusService(AgentRuntime runtime, IHostStore store, LiveHub live, TimeProvider time)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_store = store;
|
||||
_live = live;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask OnHostAsync(NodeId agent, HostSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
snapshot.AtUtcMs = now; // 以 Backend 收到的时间为准:节点的时钟不可信
|
||||
AgentRuntimeState state = _runtime.Get(agent);
|
||||
state.Host = snapshot;
|
||||
state.HostReceivedAtMs = now;
|
||||
_live.Publish(new HostSignal(agent, snapshot));
|
||||
|
||||
long minute = now - (now % 60_000);
|
||||
bool save;
|
||||
lock (_gate)
|
||||
{
|
||||
save = !_lastSavedMinute.TryGetValue(agent, out long last) || last != minute;
|
||||
if (save)
|
||||
{
|
||||
_lastSavedMinute[agent] = minute;
|
||||
}
|
||||
}
|
||||
|
||||
if (save)
|
||||
{
|
||||
await _store.SaveAsync(agent, minute, snapshot, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>最新快照;超过 15 秒没有更新就是过期(Agent 离线或者没有开启上报)。</summary>
|
||||
public (HostSnapshot? Host, bool Stale) Latest(NodeId agent)
|
||||
{
|
||||
if (!_runtime.TryGet(agent, out AgentRuntimeState? state) || state.Host is null)
|
||||
{
|
||||
return (null, true);
|
||||
}
|
||||
|
||||
return (state.Host, _time.GetUtcNow().ToUnixTimeMilliseconds() - state.HostReceivedAtMs > s_stale.TotalMilliseconds);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<HostSample>> HistoryAsync(NodeId agent, long fromMs, long toMs, CancellationToken ct) => _store.ReadAsync(agent, fromMs, toMs, ct);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>某一时刻的「线路」开关:自动连接的总开关,加上每一对(Probe,Agent)的手动设置。</summary>
|
||||
public sealed class LinkSnapshot
|
||||
{
|
||||
private readonly Dictionary<(string Probe, string Agent), bool> _overrides;
|
||||
|
||||
public LinkSnapshot(bool auto, Dictionary<(string Probe, string Agent), bool> overrides)
|
||||
{
|
||||
Auto = auto;
|
||||
_overrides = overrides;
|
||||
}
|
||||
|
||||
/// <summary>自动连接:开着时,没有手动设置的 Probe 和 Agent 都会连;关着时,只有手动开启的才连。</summary>
|
||||
public bool Auto { get; }
|
||||
|
||||
/// <summary>手动设置:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 没设置(跟随自动连接)。</summary>
|
||||
public bool? OverrideOf(string probeId, string agentId) => _overrides.TryGetValue((probeId, agentId), out bool value) ? value : null;
|
||||
|
||||
/// <summary>这一对最终连不连:手动设置优先,没有就看自动连接。</summary>
|
||||
public bool IsLinked(string probeId, string agentId) => OverrideOf(probeId, agentId) ?? Auto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 哪些(Probe,Agent)要连成线路。由面板决定、Probe 主动拨出:自动连接开着时所有 Probe 和 Agent 两两连(默认),
|
||||
/// 关着时只连手动选中的;每一对还可以手动强制开或关。手动设置存在设置表里(<c>link.<probe>.<agent></c>,值 1 或 0,空表示没设置)。
|
||||
/// 判断结果分别用在 Probe 的 Agent 列表、Agent 允许的 Probe(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
|
||||
/// </summary>
|
||||
public sealed class LinkService
|
||||
{
|
||||
private const string Prefix = "link.";
|
||||
|
||||
private readonly ISettingsStore _settings;
|
||||
|
||||
public LinkService(ISettingsStore settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async ValueTask<LinkSnapshot> SnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyDictionary<string, string> all = await _settings.GetAllAsync(ct);
|
||||
var overrides = new Dictionary<(string, string), bool>();
|
||||
foreach ((string key, string value) in all)
|
||||
{
|
||||
string[] parts = key.Split('.');
|
||||
if (parts.Length == 3 && parts[0] + "." == Prefix && value is "1" or "0")
|
||||
{
|
||||
overrides[(parts[1], parts[2])] = value == "1";
|
||||
}
|
||||
}
|
||||
|
||||
return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides);
|
||||
}
|
||||
|
||||
/// <summary>手动设置一对线路:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 取消手动设置。</summary>
|
||||
public ValueTask SetAsync(string probeId, string agentId, bool? enabled, CancellationToken ct) =>
|
||||
_settings.SetAsync($"{Prefix}{probeId}.{agentId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
|
||||
|
||||
/// <summary>Probe 或 Agent 被删除:清掉所有涉及它的手动设置。</summary>
|
||||
public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
foreach (string key in (await _settings.GetAllAsync(ct)).Keys.Where(k => k.StartsWith(Prefix, StringComparison.Ordinal) && k.Split('.').Skip(1).Contains(nodeId)))
|
||||
{
|
||||
await _settings.SetAsync(key, "", ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>MoShiro 在 Fleet 之上的接入点:注册之后补全公开地址与端口。</summary>
|
||||
public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentService _service;
|
||||
|
||||
public MoShiroEnrollmentHook(IAgentStore agents, AgentService service)
|
||||
{
|
||||
_agents = agents;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct)
|
||||
{
|
||||
AgentEnrollMeta meta = new();
|
||||
if (context.ProductMeta is { } raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
meta = raw.Deserialize(MoShiroJsonContext.Default.AgentEnrollMeta) ?? meta;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 元数据格式不对就用默认值,节点之后可以通过 hello 补全
|
||||
}
|
||||
}
|
||||
|
||||
// public_host 未提供时,取注册请求的来源 IP
|
||||
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : context.RemoteIp ?? "";
|
||||
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
|
||||
if (node.Status == NodeStatus.Active)
|
||||
{
|
||||
await _service.AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>节点上线、下线、hello、心跳:更新运行时状态,补全 Agent 信息,并推送给 SSE。</summary>
|
||||
public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
{
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly LiveHub _live;
|
||||
private readonly AgentService _service;
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly ReleaseService _releases;
|
||||
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases)
|
||||
{
|
||||
_releases = releases;
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_live = live;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct)
|
||||
{
|
||||
NodeId id = nodeEvent.Node.Id;
|
||||
if (id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
await OnProbeEventAsync(id, nodeEvent, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (nodeEvent.Kind)
|
||||
{
|
||||
case NodeEventKind.Online:
|
||||
_live.Publish(new NodeSignal("agent", id.Value, true, nodeEvent.AtMs));
|
||||
await _events.AddAsync(id, "agent_online", nodeEvent.AtMs, null, ct);
|
||||
break;
|
||||
case NodeEventKind.Offline:
|
||||
_runtime.MarkOffline(id);
|
||||
_releases.RecordStatus(id, null);
|
||||
_live.Publish(new NodeSignal("agent", id.Value, false, nodeEvent.AtMs));
|
||||
await _events.AddAsync(id, "agent_offline", nodeEvent.AtMs, null, ct);
|
||||
break;
|
||||
case NodeEventKind.Hello when nodeEvent.Hello is { } hello:
|
||||
await ApplyHelloAsync(id, hello, ct);
|
||||
break;
|
||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||
try
|
||||
{
|
||||
AgentRuntimeState state = _runtime.Get(id);
|
||||
AgentHeartbeatExtra? beat = extra.Deserialize(MoShiroJsonContext.Default.AgentHeartbeatExtra);
|
||||
state.LastHeartbeat = beat;
|
||||
state.LastHeartbeatAtMs = nodeEvent.AtMs;
|
||||
_releases.RecordStatus(id, beat?.Update);
|
||||
if (beat is not null)
|
||||
{
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs)));
|
||||
foreach (ConnectedProbe p in beat.Probes)
|
||||
{
|
||||
_nodes.ObserveProbeInBackground(p.Id.Value, p.Remote);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Probe 自己连上来了:hello 里带着它的系统、架构和构建信息;上线下线只推给 SSE,不记事件。</summary>
|
||||
private async ValueTask OnProbeEventAsync(NodeId id, NodeEvent nodeEvent, CancellationToken ct)
|
||||
{
|
||||
switch (nodeEvent.Kind)
|
||||
{
|
||||
case NodeEventKind.Online:
|
||||
_live.Publish(new NodeSignal("probe", id.Value, true, nodeEvent.AtMs));
|
||||
break;
|
||||
case NodeEventKind.Hello:
|
||||
if (nodeEvent.Hello?.Meta is { } raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
|
||||
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 格式不对就忽略
|
||||
}
|
||||
}
|
||||
|
||||
await _releases.OfferToNodeAsync(id, ct);
|
||||
break;
|
||||
case NodeEventKind.Offline:
|
||||
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
|
||||
_releases.RecordStatus(id, null);
|
||||
break;
|
||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||
try
|
||||
{
|
||||
_releases.RecordStatus(id, extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra)?.Update);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ApplyHelloAsync(NodeId id, HelloParams hello, CancellationToken ct)
|
||||
{
|
||||
var kinds = new List<string>();
|
||||
foreach (string capability in hello.Capabilities)
|
||||
{
|
||||
if (capability.StartsWith(MoShiroCapabilities.KindsPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
kinds.AddRange(capability[MoShiroCapabilities.KindsPrefix.Length..].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
}
|
||||
|
||||
AgentHelloMeta meta = new();
|
||||
if (hello.Meta is { } raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
meta = raw.Deserialize(MoShiroJsonContext.Default.AgentHelloMeta) ?? meta;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
byte[]? spki = null;
|
||||
if (meta.TlsSpki is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
spki = Convert.FromBase64String(meta.TlsSpki);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts), ct);
|
||||
|
||||
// 端口或证书指纹变了,Probe 需要新的列表
|
||||
AgentRecord? after = await _agents.GetAsync(id, ct);
|
||||
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
|
||||
await _releases.OfferToNodeAsync(id, ct);
|
||||
if (before is not null && after is not null
|
||||
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts))
|
||||
&& after.Status == NodeStatus.Active)
|
||||
{
|
||||
await _service.AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(LocalLimits))]
|
||||
internal sealed partial class LocalLimitsJson : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
@@ -0,0 +1,193 @@
|
||||
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);
|
||||
|
||||
public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<ProbeConnection> Connections)
|
||||
{
|
||||
public bool Revoked => Probe.RevokedAtMs is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。
|
||||
/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话,Probe 到面板的连接也会被拒绝。
|
||||
/// 一条线路是一对(Probe,Agent)。
|
||||
/// </summary>
|
||||
public sealed partial class ProbeService
|
||||
{
|
||||
private readonly IProbeStore _probes;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
{
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
_probes = probes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
_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);
|
||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||
return [.. probes.Select(p => View(p, agents))];
|
||||
}
|
||||
|
||||
public async ValueTask<ProbeView?> GetAsync(string id, CancellationToken ct) =>
|
||||
await _probes.GetAsync(id, ct) is { } probe ? View(probe, await _agents.ListAsync(ct)) : null;
|
||||
|
||||
public async ValueTask<Result<ProbeView>> RenameAsync(string id, string name, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(id, ct) is not { } probe)
|
||||
{
|
||||
return Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
string trimmed = name.Trim();
|
||||
if (!NamePattern().IsMatch(trimmed))
|
||||
{
|
||||
return ValidationError.Fail<ProbeView>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
|
||||
}
|
||||
|
||||
if ((await _probes.ListAsync(ct)).Any(p => p.Id != id && string.Equals(p.Name, trimmed, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Result<ProbeView>.Fail(MoShiroErrors.NameConflict, $"a probe named {trimmed} already exists");
|
||||
}
|
||||
|
||||
await _probes.SaveAsync(probe with { Name = trimmed }, ct);
|
||||
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)
|
||||
{
|
||||
if (await _probes.GetAsync(id, ct) is null)
|
||||
{
|
||||
return Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
await _probes.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
|
||||
}
|
||||
|
||||
/// <summary>删除:Probe 身份和它的线路从面板消失;已经入库的数据按保留策略自然过期。</summary>
|
||||
public async ValueTask<bool> DeleteAsync(string id, CancellationToken ct)
|
||||
{
|
||||
bool deleted = await _probes.DeleteAsync(id, ct);
|
||||
if (deleted)
|
||||
{
|
||||
await _nodes.ForgetAsync(id, ct);
|
||||
await _links.ForgetNodeAsync(id, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>让某个 Agent 断开这个 Probe(Probe 会按退避重连,除非它已经被吊销)。</summary>
|
||||
public async ValueTask<Result<bool>> DisconnectAsync(string probeId, NodeId agentId, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(probeId, ct) is null)
|
||||
{
|
||||
return Result<bool>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
if (await _agents.GetAsync(agentId, ct) is null)
|
||||
{
|
||||
return Result<bool>.Fail(MoShiroErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
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>();
|
||||
foreach ((NodeId agentId, ConnectedProbeInfo info) in _runtime.ConnectionsOf(probe.Id))
|
||||
{
|
||||
AgentRecord? agent = agents.FirstOrDefault(a => a.Id == agentId);
|
||||
if (agent is not null)
|
||||
{
|
||||
connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user