shared: add independent IPv4 and IPv6 collection [CI]
ci/woodpecker/pr/verify Pipeline was successful
ci/woodpecker/pr/web-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-07 11:44:01 +08:00
1 parent c974a7dddb
commit 0eda0600cc
60 files changed
+699 -137

No files matched your search

@@ -34,11 +34,11 @@ public sealed class AgentEventService
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));
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at, p.IpVersion) : 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);
state.RemoveProbe(e.Detail, e.Probe?.IpVersion ?? IpVersions.V4);
break;
case AgentEventKinds.EndpointChanged:
state.CountEndpointChange();
@@ -5,7 +5,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs);
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
public sealed class AgentRuntimeState
@@ -19,22 +19,22 @@ public sealed class AgentRuntimeState
public bool ProbeConnected => Probes.Count > 0;
public bool IsConnected(string probeId) => Probes.Any(p => p.Id == probeId);
public bool IsConnected(string probeId, string ipVersion = IpVersions.V4) => Probes.Any(p => p.Id == probeId && p.IpVersion == ipVersion);
/// <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]);
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id || p.IpVersion != info.IpVersion), info]);
}
}
public void RemoveProbe(string id)
public void RemoveProbe(string id, string ipVersion = IpVersions.V4)
{
lock (_gate)
{
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id)]);
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id || p.IpVersion != ipVersion)]);
}
}
@@ -1,3 +1,5 @@
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using Fleet.Core;
using Fleet.Server;
@@ -49,6 +51,16 @@ public sealed partial class AgentService
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host);
}
private static bool ValidHost(string host, string ipVersion)
{
if (IPAddress.TryParse(host, out IPAddress? address))
{
return !address.IsIPv4MappedToIPv6 && address.AddressFamily == (ipVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork);
}
return host.Length <= 253 && Uri.CheckHostName(host) == UriHostNameType.Dns;
}
public async ValueTask<Result<AgentView>> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
@@ -57,11 +69,16 @@ public sealed partial class AgentService
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
}
if (patch.PublicHost is not null && !HostPattern().IsMatch(patch.PublicHost))
if (patch.PublicHost is not null && !ValidHost(patch.PublicHost, IpVersions.V4))
{
errors["public_host"] = "must be a host name or IP address";
}
if (patch.Ipv6Host is not null && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
{
errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection";
}
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";
@@ -78,7 +95,7 @@ public sealed partial class AgentService
return Result<AgentView>.Fail(error);
}
if (patch.Name is not null || patch.PublicHost is not null)
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null)
{
await AfterAgentSetChangedAsync(ct);
}
@@ -139,6 +156,7 @@ public sealed partial class AgentService
AgentId = agent.Id,
Name = agent.Name,
Host = agent.PublicHost,
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
TcpPort = agent.ProbeTcpPort,
UdpPorts = [.. agent.ProbeUdpPorts],
Spki = Convert.ToBase64String(agent.TlsSpki),
@@ -170,7 +188,4 @@ public sealed partial class AgentService
[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();
}
@@ -99,7 +99,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
_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)));
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs, p.IpVersion)));
foreach (ConnectedProbe p in beat.Probes)
{
_nodes.ObserveProbeInBackground(p.Id.Value, p.Remote);
@@ -196,14 +196,14 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
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);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported), 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))
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported)
&& after.Status == NodeStatus.Active)
{
await _service.AfterAgentSetChangedAsync(ct);
@@ -13,7 +13,7 @@ public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
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 ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<ProbeConnection> Connections)
{
@@ -178,7 +178,7 @@ public sealed partial class ProbeService
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));
connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs, info.IpVersion));
}
}