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
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user