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
@@ -81,7 +81,7 @@ public static class AgentEndpoints
|
||||
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
Result<AgentView> result = await agents.PatchAsync(
|
||||
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
|
||||
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicHostAuto = request.PublicHostAuto, Ipv6HostAuto = request.Ipv6HostAuto, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
|
||||
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
|
||||
@@ -103,7 +103,7 @@ public static class Mappers
|
||||
: null;
|
||||
return new AgentDto(
|
||||
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
|
||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view));
|
||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual);
|
||||
}
|
||||
|
||||
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
|
||||
|
||||
@@ -40,13 +40,16 @@ public sealed record AgentDto(
|
||||
DateTimeOffset? LastSeenAt,
|
||||
string Ipv6Host = "",
|
||||
bool Ipv6Supported = false,
|
||||
Ipv6DiagnosticDto? Ipv6 = null);
|
||||
Ipv6DiagnosticDto? Ipv6 = null,
|
||||
bool PublicHostAuto = true,
|
||||
bool Ipv6HostAuto = true);
|
||||
|
||||
public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State,
|
||||
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
|
||||
|
||||
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null);
|
||||
/// <param name="PublicHostAuto">true:撤销手填,回到自动识别(<c>ipv6_host_auto</c> 同理)。手填 <c>public_host</c> 或 <c>ipv6_host</c> 就是手动,自动识别不再覆盖。</param>
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
||||
|
||||
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 8;
|
||||
public const int CurrentVersion = 9;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -120,6 +120,17 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 8");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 9)
|
||||
{
|
||||
// 对外地址自动识别:手填的标记为 manual,自动识别不会覆盖;Agent 上报的地址存下来,撤销手填时马上能重新识别
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("ALTER TABLE agents ADD COLUMN public_host_manual INTEGER NOT NULL DEFAULT 0");
|
||||
connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_host_manual INTEGER NOT NULL DEFAULT 0");
|
||||
connection.Exec("ALTER TABLE agents ADD COLUMN reported_addresses TEXT NOT NULL DEFAULT '[]'");
|
||||
connection.Exec("PRAGMA user_version = 9");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
||||
{
|
||||
private const string AgentColumns =
|
||||
"id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " +
|
||||
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics";
|
||||
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses";
|
||||
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
@@ -60,11 +60,18 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
||||
return Result<AgentRecord>.Fail(MoShiroErrors.NameConflict, "another agent already uses this name");
|
||||
}
|
||||
|
||||
// 手填就标记为 manual(自动识别不再覆盖);撤销手填回到自动,值留到下一次识别
|
||||
bool hostAuto = patch.PublicHostAuto == true;
|
||||
bool v6Auto = patch.Ipv6HostAuto == true;
|
||||
c.Exec(
|
||||
"UPDATE agents SET name = @name, labels = @labels, public_host = @host, ipv6_host = @v6, public_visible = @visible, row_version = row_version + 1 WHERE id = @id",
|
||||
"UPDATE agents SET name = @name, labels = @labels, public_host = @host, ipv6_host = @v6, public_host_manual = @hostManual, ipv6_host_manual = @v6Manual, " +
|
||||
"public_visible = @visible, row_version = row_version + 1 WHERE id = @id",
|
||||
("@id", id.Value), ("@name", patch.Name ?? current.Name),
|
||||
("@labels", StorageJson.ToJson(patch.Labels ?? current.Labels)),
|
||||
("@host", patch.PublicHost ?? current.PublicHost), ("@v6", patch.Ipv6Host ?? current.Ipv6Host), ("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0));
|
||||
("@host", hostAuto ? current.PublicHost : patch.PublicHost ?? current.PublicHost), ("@v6", v6Auto ? current.Ipv6Host : patch.Ipv6Host ?? current.Ipv6Host),
|
||||
("@hostManual", hostAuto ? 0 : patch.PublicHost is not null || current.PublicHostManual ? 1 : 0),
|
||||
("@v6Manual", v6Auto ? 0 : patch.Ipv6Host is not null || current.Ipv6HostManual ? 1 : 0),
|
||||
("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0));
|
||||
return Result<AgentRecord>.Ok(GetInternal(c, id)!);
|
||||
}, ct);
|
||||
|
||||
@@ -117,9 +124,21 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
||||
{
|
||||
c.Exec(
|
||||
"UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), " +
|
||||
"probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, row_version = row_version + 1 WHERE id = @id",
|
||||
"probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, " +
|
||||
"reported_addresses = COALESCE(@addr, reported_addresses), row_version = row_version + 1 WHERE id = @id",
|
||||
("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson),
|
||||
("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson));
|
||||
("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson),
|
||||
("@addr", hello.Addresses is null ? null : StorageJson.ToJson(hello.Addresses)));
|
||||
}, ct);
|
||||
|
||||
public ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(c =>
|
||||
{
|
||||
c.Exec(
|
||||
"UPDATE agents SET public_host = CASE WHEN public_host_manual = 0 AND @host IS NOT NULL THEN @host ELSE public_host END, " +
|
||||
"ipv6_host = CASE WHEN ipv6_host_manual = 0 AND @v6 IS NOT NULL THEN @v6 ELSE ipv6_host END, row_version = row_version + 1 " +
|
||||
"WHERE id = @id AND ((public_host_manual = 0 AND @host IS NOT NULL AND public_host <> @host) OR (ipv6_host_manual = 0 AND @v6 IS NOT NULL AND ipv6_host <> @v6))",
|
||||
("@id", id.Value), ("@host", publicHost), ("@v6", ipv6Host));
|
||||
}, ct);
|
||||
|
||||
public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) =>
|
||||
@@ -244,7 +263,10 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
||||
r.GetInt64(18),
|
||||
r.GetString(19),
|
||||
r.GetInt64(20) != 0,
|
||||
r.NullableString(21));
|
||||
r.NullableString(21),
|
||||
r.GetInt64(22) != 0,
|
||||
r.GetInt64(23) != 0,
|
||||
StorageJson.Strings(r.GetString(24)));
|
||||
|
||||
private static NodeRecord ToNode(AgentRecord a, string? metaJson) =>
|
||||
new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs);
|
||||
|
||||
Reference in new issue
Block a user