Files
MoShiro/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
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]
2026-10-08 03:21:44 +08:00

116 lines
3.9 KiB
C#

using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// Probe 要连接的 Agent 列表:由面板作为期望状态(<c>probe_agents</c>)直接下发;面板连不上时用上一次保存的 <c>agents.json</c>。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary>
public sealed class AgentDirectory
{
public const string FileName = "agents.json";
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private readonly TrustAnchor _trust;
private List<KnownAgent> _agents = [];
private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache))
{
try
{
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
_agents = saved.Agents;
_version = saved.Version;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{
_log.Warn("agents.json is unreadable, waiting for the panel", ("err", ex.Message));
}
}
}
/// <summary>列表变化(调和连接)。</summary>
public event Action? Changed;
public long Version
{
get
{
lock (_gate)
{
return _version;
}
}
}
public IReadOnlyList<KnownAgent> Agents
{
get
{
lock (_gate)
{
return [.. _agents];
}
}
}
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(ProbeAgentList result)
{
var next = new List<KnownAgent>();
_trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
if (!string.IsNullOrWhiteSpace(agent.Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
}
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate)
{
if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
{
return;
}
_version = result.Version;
_agents = next;
}
try
{
_store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new AgentsCache { Version = result.Version, Agents = next }, ProbeConfigContext.Default.AgentsCache));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save agents.json", ("err", ex.Message));
}
_log.Info("agent list updated", ("version", result.Version), ("agents", next.Count));
Changed?.Invoke();
}
private static bool Same(List<KnownAgent> a, List<KnownAgent> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
}