feat: detect an agent's public IPv4 and IPv6 addresses automatically, manual values still win [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-07 18:12:17 +08:00
1 parent 9966ea5d63
commit f03988d68b
26 files changed
+517 -33

No files matched your search

@@ -0,0 +1,40 @@
using System.Net;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// 自动识别 Agent 的对外地址:Agent 上报本机的公网地址,注册请求的来源地址作为补充(来源是私网或隧道出口时不用)。
/// 管理员手填的(<c>*_manual</c>)永远优先;已经选中的自动地址还在候选里就不换,不会因为网卡顺序变化来回改。
/// </summary>
public static class AgentAddressPolicy
{
/// <summary>返回要写入的 <c>(public_host, ipv6_host)</c>;没有候选的那一项是 null(保持不变)。</summary>
public static (string? PublicHost, string? Ipv6Host) Choose(AgentRecord current, IReadOnlyList<string> reported, string? observed)
{
var v4 = new List<string>();
var v6 = new List<string>();
foreach (string text in reported.Append(observed ?? ""))
{
if (!IPAddress.TryParse(text, out IPAddress? address))
{
continue;
}
if (address.AddressFamily == AddressFamily.InterNetwork && PublicAddress.IsPublicIpv4(address) && !v4.Contains(address.ToString()))
{
v4.Add(address.ToString());
}
else if (address.AddressFamily == AddressFamily.InterNetworkV6 && PublicAddress.IsGlobalIpv6(address) && !v6.Contains(address.ToString()))
{
v6.Add(address.ToString());
}
}
return (current.PublicHostManual ? null : Pick(v4, current.PublicHost), current.Ipv6HostManual ? null : Pick(v6, current.Ipv6Host));
}
private static string? Pick(List<string> candidates, string current) =>
candidates.Count == 0 ? null : candidates.Contains(current) ? current : candidates[0];
}
@@ -69,12 +69,12 @@ public sealed partial class AgentService
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
}
if (patch.PublicHost is not null && !ValidHost(patch.PublicHost, IpVersions.V4))
if (patch.PublicHost is not null && patch.PublicHostAuto != true && !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))
if (patch.Ipv6Host is not null && patch.Ipv6HostAuto != true && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
{
errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection";
}
@@ -95,7 +95,15 @@ public sealed partial class AgentService
return Result<AgentView>.Fail(error);
}
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null)
if (patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
{
// 撤销手填:马上按已经上报的地址重新识别
(string? host, string? v6) = AgentAddressPolicy.Choose(agent, agent.ReportedAddresses ?? [], null);
await _agents.ApplyAutoAddressesAsync(id, host, v6, ct);
agent = await _agents.GetAsync(id, ct) ?? agent;
}
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null || patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
{
await AfterAgentSetChangedAsync(ct);
}
@@ -40,9 +40,16 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
}
}
// public_host 未提供时,取注册请求的来源 IP
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : context.RemoteIp ?? "";
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
{
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
await _agents.ApplyAutoAddressesAsync(node.Id, null, observedV6, ct);
}
if (node.Status == NodeStatus.Active)
{
await _service.AfterAgentSetChangedAsync(ct);
@@ -218,15 +225,22 @@ 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);
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json), ct);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses), ct);
// 端口或证书指纹变了,Probe 需要新的列表
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
{
(string? publicHost, string? ipv6Host) = AgentAddressPolicy.Choose(reported, meta.Addresses, null);
await _agents.ApplyAutoAddressesAsync(id, publicHost, ipv6Host, ct);
}
// 端口、证书指纹或对外地址变了,Probe 需要新的列表
AgentRecord? after = await _agents.GetAsync(id, ct);
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
await _releases.OfferToNodeAsync(id, ct);
await _schedule.SendToNodeAsync(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.Ipv6Supported != after.Ipv6Supported)
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host)
&& after.Status == NodeStatus.Active)
{
await _service.AfterAgentSetChangedAsync(ct);
@@ -25,7 +25,10 @@ public sealed record AgentRecord(
long RowVersion,
string Ipv6Host = "",
bool Ipv6Supported = false,
string? Ipv6DiagnosticsJson = null);
string? Ipv6DiagnosticsJson = null,
bool PublicHostManual = false,
bool Ipv6HostManual = false,
IReadOnlyList<string>? ReportedAddresses = null);
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
public sealed class AgentPatch
@@ -38,6 +41,11 @@ public sealed class AgentPatch
public string? Ipv6Host { get; init; }
/// <summary>为 true:撤销手填,回到自动识别(<see cref="PublicHost"/> 同时给出则忽略)。</summary>
public bool? PublicHostAuto { get; init; }
public bool? Ipv6HostAuto { get; init; }
public bool? PublicVisible { get; init; }
}
@@ -50,7 +58,8 @@ public sealed record AgentHello(
int? ProbeTcpPort,
IReadOnlyList<int>? ProbeUdpPorts,
bool Ipv6Supported = false,
string? Ipv6DiagnosticsJson = null);
string? Ipv6DiagnosticsJson = null,
IReadOnlyList<string>? Addresses = null);
/// <summary>注册之后 MoShiro 的补充:公开地址和端口。</summary>
public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList<int> ProbeUdpPorts, IReadOnlyList<string> Kinds);
@@ -26,6 +26,9 @@ public interface IAgentStore
ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct);
/// <summary>写入自动识别出的地址;管理员手填的那一项不动。为 null 的项不改。</summary>
ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct);
ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct);
ValueTask<string?> GetTaskHashAsync(NodeId id, CancellationToken ct);