diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 50ae208..cec7db4 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -290,6 +290,7 @@ public sealed class AgentApp : IDisposable { Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), Ipv6Supported = _ipv6Supported, + Addresses = [.. LocalAddresses.Sample()], Platform = MoShiro.Measurement.PlatformDetector.Detect(), TlsSpki = _identity.File.TlsSpki, ProbeTcpPort = ControlPort, diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 06c33ba..dcf4fa9 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4394,6 +4394,14 @@ "$ref": "#/components/schemas/Ipv6DiagnosticDto" } ] + }, + "public_host_auto": { + "type": "boolean", + "default": true + }, + "ipv6_host_auto": { + "type": "boolean", + "default": true } } }, @@ -4461,6 +4469,18 @@ "null", "string" ] + }, + "public_host_auto": { + "type": [ + "null", + "boolean" + ] + }, + "ipv6_host_auto": { + "type": [ + "null", + "boolean" + ] } } }, diff --git a/backend/shared/MoShiro.Measurement/Platform/LocalAddresses.cs b/backend/shared/MoShiro.Measurement/Platform/LocalAddresses.cs new file mode 100644 index 0000000..0e90ed0 --- /dev/null +++ b/backend/shared/MoShiro.Measurement/Platform/LocalAddresses.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using MoShiro.Protocol; + +namespace MoShiro.Measurement; + +/// +/// 本机的公网地址:Agent 注册之后在 hello 里上报,面板用它自动填 public_host 和 ipv6_host,不需要管理员手填。 +/// 只列出「别人能拨进来」的地址:IPv4 要排除私网、CGNAT、回环、链路本地;IPv6 只要全球单播(2000::/3), +/// 并且排除临时地址、tentative、deprecated 和文档/6to4/Teredo 段。读不到网卡信息时返回空,面板退回到管理员手填或观察到的来源地址。 +/// +public static class LocalAddresses +{ + public static IReadOnlyList Sample() + { + var result = new List(); + HashSet unusable = OperatingSystem.IsLinux() ? UnusableIpv6(Read("/proc/net/if_inet6")) : []; + try + { + foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces()) + { + if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) + { + continue; + } + + foreach (UnicastIPAddressInformation unicast in adapter.GetIPProperties().UnicastAddresses) + { + IPAddress address = unicast.Address; + bool usable = address.AddressFamily == AddressFamily.InterNetwork + ? PublicAddress.IsPublicIpv4(address) + : PublicAddress.IsGlobalIpv6(address) && !unusable.Contains(address.ToString()) && !IsTemporary(unicast); + if (usable && !result.Contains(address.ToString())) + { + result.Add(address.ToString()); + } + } + } + } + catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException) + { + return []; + } + + // 稳定顺序:同一台机器每次上报的顺序一样,面板不会因为顺序变化而改地址 + result.Sort(StringComparer.Ordinal); + return result; + } + + /// Linux 的 /proc/net/if_inet6:临时(0x01)、dadfailed(0x08)、deprecated(0x20)、tentative(0x40)的地址不能当作对外地址。 + public static HashSet UnusableIpv6(string? procNetIfInet6) + { + var result = new HashSet(StringComparer.Ordinal); + if (procNetIfInet6 is null) + { + return result; + } + + foreach (string line in procNetIfInet6.Split('\n')) + { + string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length != 6 || fields[0].Length != 32 || !uint.TryParse(fields[4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) || (flags & 0x69) == 0) + { + continue; + } + + var bytes = new byte[16]; + for (int i = 0; i < 16; i++) + { + bytes[i] = byte.Parse(fields[0].AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + result.Add(new IPAddress(bytes).ToString()); + } + + return result; + } + + // Windows 和 macOS:随机后缀就是隐私(临时)地址 + private static bool IsTemporary(UnicastIPAddressInformation unicast) + { + try + { + return OperatingSystem.IsWindows() + && (unicast.SuffixOrigin == SuffixOrigin.Random || unicast.DuplicateAddressDetectionState is DuplicateAddressDetectionState.Tentative or DuplicateAddressDetectionState.Duplicate); + } + catch (PlatformNotSupportedException) + { + return false; + } + } + + private static string? Read(string path) + { + try + { + return File.Exists(path) ? File.ReadAllText(path) : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/backend/shared/MoShiro.Protocol/PublicAddress.cs b/backend/shared/MoShiro.Protocol/PublicAddress.cs new file mode 100644 index 0000000..3838c9b --- /dev/null +++ b/backend/shared/MoShiro.Protocol/PublicAddress.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Net.Sockets; + +namespace MoShiro.Protocol; + +/// 哪些地址算「公网上能拨进来的地址」:Agent 上报本机地址和面板自动识别共用同一套判断。 +public static class PublicAddress +{ + public static bool IsPublicIpv4(IPAddress address) + { + if (address.AddressFamily != AddressFamily.InterNetwork) + { + return false; + } + + byte[] b = address.GetAddressBytes(); + return !(b[0] is 0 or 10 or 127 or >= 224 + || (b[0] == 100 && b[1] is >= 64 and <= 127) + || (b[0] == 169 && b[1] == 254) + || (b[0] == 172 && b[1] is >= 16 and <= 31) + || (b[0] == 192 && b[1] == 168) + || (b[0] == 192 && b[1] == 0 && b[2] is 0 or 2) + || (b[0] == 198 && b[1] is 18 or 19) + || (b[0] == 198 && b[1] == 51 && b[2] == 100) + || (b[0] == 203 && b[1] == 0 && b[2] == 113)); + } + + public static bool IsGlobalIpv6(IPAddress address) + { + if (address.AddressFamily != AddressFamily.InterNetworkV6 || address.IsIPv4MappedToIPv6) + { + return false; + } + + byte[] b = address.GetAddressBytes(); + if ((b[0] & 0xE0) != 0x20) + { + return false; + } + + // 2001:db8::/32 文档、2002::/16 6to4、2001::/32 Teredo + return !((b[0] == 0x20 && b[1] == 0x01 && b[2] == 0x0d && b[3] == 0xb8) + || (b[0] == 0x20 && b[1] == 0x02) + || (b[0] == 0x20 && b[1] == 0x01 && b[2] == 0 && b[3] == 0)); + } +} diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index b9f6da2..9442c7c 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -326,6 +326,10 @@ public sealed class AgentHelloMeta [JsonPropertyName("tls_spki")] public string? TlsSpki { get; set; } + /// 本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 public_host 和 ipv6_host,管理员手填的优先。 + [JsonPropertyName("addresses")] + public List? Addresses { get; set; } + [JsonPropertyName("probe_tcp_port")] public int? ProbeTcpPort { get; set; } diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/LocalAddressesTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/LocalAddressesTests.cs new file mode 100644 index 0000000..1f5ce96 --- /dev/null +++ b/backend/shared/tests/MoShiro.Measurement.Tests/LocalAddressesTests.cs @@ -0,0 +1,36 @@ +using MoShiro.Measurement; + +namespace MoShiro.Measurement.Tests; + +public sealed class LocalAddressesTests +{ + [Fact] + public void Linux_TemporaryTentativeDeprecatedAndFailedAddressesAreNotUsable() + { + const string text = + "2409c0d0000000000000000000000001 02 40 00 80 eth0\n" + // 稳定地址:可用 + "2409c0d0000000000000000000000002 02 40 00 01 eth0\n" + // 临时(隐私)地址 + "2409c0d0000000000000000000000003 02 40 00 40 eth0\n" + // tentative + "2409c0d0000000000000000000000004 02 40 00 20 eth0\n" + // deprecated + "2409c0d0000000000000000000000005 02 40 00 08 eth0\n"; // dad 失败 + HashSet unusable = LocalAddresses.UnusableIpv6(text); + Assert.DoesNotContain("2409:c0d0::1", unusable); + Assert.Contains("2409:c0d0::2", unusable); + Assert.Contains("2409:c0d0::3", unusable); + Assert.Contains("2409:c0d0::4", unusable); + Assert.Contains("2409:c0d0::5", unusable); + Assert.Empty(LocalAddresses.UnusableIpv6(null)); + } + + [Fact] + public void Sample_NeverReturnsLoopbackPrivateOrLinkLocalAddresses() + { + foreach (string text in LocalAddresses.Sample()) + { + var address = System.Net.IPAddress.Parse(text); + Assert.True(address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork + ? MoShiro.Protocol.PublicAddress.IsPublicIpv4(address) + : MoShiro.Protocol.PublicAddress.IsGlobalIpv6(address)); + } + } +} diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/PublicAddressTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/PublicAddressTests.cs new file mode 100644 index 0000000..1b5ff5c --- /dev/null +++ b/backend/shared/tests/MoShiro.Protocol.Tests/PublicAddressTests.cs @@ -0,0 +1,35 @@ +using System.Net; +using MoShiro.Protocol; + +namespace MoShiro.Protocol.Tests; + +public sealed class PublicAddressTests +{ + [Theory] + [InlineData("8.8.8.8", true)] + [InlineData("23.176.40.145", true)] + [InlineData("10.1.2.3", false)] + [InlineData("172.16.0.1", false)] + [InlineData("172.32.0.1", true)] + [InlineData("192.168.2.162", false)] + [InlineData("100.64.0.1", false)] + [InlineData("169.254.1.1", false)] + [InlineData("127.0.0.1", false)] + [InlineData("203.0.113.10", false)] + [InlineData("224.0.0.1", false)] + public void Ipv4_OnlyPublicAddressesCountAsReachable(string text, bool expected) => + Assert.Equal(expected, PublicAddress.IsPublicIpv4(IPAddress.Parse(text))); + + [Theory] + [InlineData("2409:8a55:6c45:62d1::69d", true)] + [InlineData("2606:4700:4700::1111", true)] + [InlineData("fd00::1", false)] + [InlineData("fe80::1", false)] + [InlineData("::1", false)] + [InlineData("2001:db8::1", false)] + [InlineData("2002:c000:204::1", false)] + [InlineData("2001:0:4136:e378::1", false)] + [InlineData("::ffff:8.8.8.8", false)] + public void Ipv6_OnlyGlobalUnicastCountsAsReachable(string text, bool expected) => + Assert.Equal(expected, PublicAddress.IsGlobalIpv6(IPAddress.Parse(text))); +} diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 6eada2e..63ed0c3 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -81,7 +81,7 @@ public static class AgentEndpoints private static async Task PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct) { Result 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); diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index f53902c..0a7f60b 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -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) diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index f341544..fa639aa 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -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); /// merge-patch:缺省或为 null 的字段不修改。 -public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null); +/// true:撤销手填,回到自动识别(ipv6_host_auto 同理)。手填 public_host 或 ipv6_host 就是手动,自动识别不再覆盖。 +public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null); /// agent 或 probe:用这个令牌注册出来的是哪种节点。 public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent"); diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentAddressPolicy.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentAddressPolicy.cs new file mode 100644 index 0000000..91a5d7d --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentAddressPolicy.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.Sockets; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +/// +/// 自动识别 Agent 的对外地址:Agent 上报本机的公网地址,注册请求的来源地址作为补充(来源是私网或隧道出口时不用)。 +/// 管理员手填的(*_manual)永远优先;已经选中的自动地址还在候选里就不换,不会因为网卡顺序变化来回改。 +/// +public static class AgentAddressPolicy +{ + /// 返回要写入的 (public_host, ipv6_host);没有候选的那一项是 null(保持不变)。 + public static (string? PublicHost, string? Ipv6Host) Choose(AgentRecord current, IReadOnlyList reported, string? observed) + { + var v4 = new List(); + var v6 = new List(); + 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 candidates, string current) => + candidates.Count == 0 ? null : candidates.Contains(current) ? current : candidates[0]; +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index bb7f3ca..f46503a 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -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.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); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index b406ff2..5e356ac 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index f146fec..90a0a0f 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -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? ReportedAddresses = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -38,6 +41,11 @@ public sealed class AgentPatch public string? Ipv6Host { get; init; } + /// 为 true:撤销手填,回到自动识别( 同时给出则忽略)。 + 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? ProbeUdpPorts, bool Ipv6Supported = false, - string? Ipv6DiagnosticsJson = null); + string? Ipv6DiagnosticsJson = null, + IReadOnlyList? Addresses = null); /// 注册之后 MoShiro 的补充:公开地址和端口。 public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList ProbeUdpPorts, IReadOnlyList Kinds); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index 0ed3d13..a4a4ba4 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -26,6 +26,9 @@ public interface IAgentStore ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct); + /// 写入自动识别出的地址;管理员手填的那一项不动。为 null 的项不改。 + ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct); + ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct); ValueTask GetTaskHashAsync(NodeId id, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index a2b48d4..2a735d8 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 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(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 1b04b82..99b3013 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -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.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.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); diff --git a/backend/tests/MoShiro.Backend.Tests/AddressTests.cs b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs new file mode 100644 index 0000000..2c1705f --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Text.Json.Nodes; +using MoShiro.TestSupport; + +namespace MoShiro.Backend.Tests; + +/// public_host 和 ipv6_host 自动识别:Agent 上报公网地址,手填的优先,可以撤销手填。 +[Collection("probe-e2e")] +public sealed class AddressTests +{ + private const string V4 = "8.8.4.4"; + private const string V6 = "2606:4700:4700::1111"; + private readonly BackendFixture _backend; + + public AddressTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task AgentAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + id); + + private async Task PatchAsync(string id, JsonObject body) + { + using HttpResponseMessage response = await _backend.Admin.PatchAsync("/api/admin/agents/" + id, BackendFixture.Json(body)); + string text = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, text); + return JsonNode.Parse(text)!; + } + + [Fact] + public async Task Hello_FillsPublicHostAndIpv6Host_PrivateAndTemporaryAddressesAreIgnored() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(hello: false); + agent.Addresses = ["10.0.0.5", "192.168.1.9", "fd00::1", "fe80::1", V6, V4]; + await agent.HelloAsync(); + + JsonNode view = null!; + await BackendFixture.EventuallyAsync(async () => (view = await AgentAsync(agent.NodeId.Value))["ipv6_host"]!.GetValue() == V6); + Assert.Equal(V4, view["public_host"]!.GetValue()); + Assert.True(view["public_host_auto"]!.GetValue()); + Assert.True(view["ipv6_host_auto"]!.GetValue()); + } + + [Fact] + public async Task ManualValues_WinOverAutoDetection_AndCanBeRevokedBackToAuto() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(hello: false); + string id = agent.NodeId.Value; + agent.Addresses = [V4, V6]; + await agent.HelloAsync(); + await BackendFixture.EventuallyAsync(async () => (await AgentAsync(id))["ipv6_host"]!.GetValue() == V6); + + // 手填:标记为手动,之后 Agent 再上报别的地址也不覆盖 + JsonNode manual = await PatchAsync(id, new JsonObject { ["public_host"] = "agent.example.net", ["ipv6_host"] = "" }); + Assert.False(manual["public_host_auto"]!.GetValue()); + Assert.False(manual["ipv6_host_auto"]!.GetValue()); + agent.Addresses = ["1.1.1.1", "2606:4700:4700::1001"]; + await agent.HelloAsync(); + await Task.Delay(300); + JsonNode kept = await AgentAsync(id); + Assert.Equal("agent.example.net", kept["public_host"]!.GetValue()); + Assert.Equal("", kept["ipv6_host"]!.GetValue()); // 空 = 手动关闭 IPv6 + + // 撤销手填:马上按最近一次上报的地址重新识别 + JsonNode auto = await PatchAsync(id, new JsonObject { ["public_host_auto"] = true, ["ipv6_host_auto"] = true }); + Assert.Equal("1.1.1.1", auto["public_host"]!.GetValue()); + Assert.Equal("2606:4700:4700::1001", auto["ipv6_host"]!.GetValue()); + Assert.True(auto["public_host_auto"]!.GetValue()); + } + + [Fact] + public async Task AutoDetectedAddress_StaysWhileItIsStillReported_SoReorderingDoesNotFlap() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(hello: false); + string id = agent.NodeId.Value; + agent.Addresses = [V4, "9.9.9.9"]; + await agent.HelloAsync(); + await BackendFixture.EventuallyAsync(async () => (await AgentAsync(id))["public_host"]!.GetValue() == V4); + + agent.Addresses = ["1.0.0.1", V4]; + await agent.HelloAsync(); + await Task.Delay(300); + Assert.Equal(V4, (await AgentAsync(id))["public_host"]!.GetValue()); + + // 原来的地址不见了才换 + agent.Addresses = ["1.0.0.1"]; + await agent.HelloAsync(); + await BackendFixture.EventuallyAsync(async () => (await AgentAsync(id))["public_host"]!.GetValue() == "1.0.0.1"); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index 5ba8869..56b3b18 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -49,6 +49,10 @@ public sealed class AgentHarness : IAsyncDisposable Result enrolled = await EnrollmentClient.EnrollAsync(http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); + // 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖) + using HttpResponseMessage patched = await backend.Admin.PatchAsync("/api/admin/agents/" + enrolled.Value.NodeId, BackendFixture.Json(new JsonObject { ["public_host"] = "127.0.0.1" })); + Assert.True(patched.IsSuccessStatusCode, await patched.Content.ReadAsStringAsync()); + var config = new AgentConfig { Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory }, diff --git a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs index 4fa21d5..1e27dc1 100644 --- a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs +++ b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs @@ -139,12 +139,16 @@ public sealed class AgentSimulator : IAsyncDisposable } } + /// hello 里上报的本机公网地址(缺省不上报)。 + public string[]? Addresses { get; set; } + public async Task HelloAsync(string[]? kinds = null) { var meta = JsonSerializer.SerializeToElement( new AgentHelloMeta { TlsSpki = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + Addresses = Addresses is null ? null : [.. Addresses], ProbeTcpPort = 7443, ProbeUdpPorts = [7443], LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 }, diff --git a/docs/api.md b/docs/api.md index 91a8113..3be38ba 100644 --- a/docs/api.md +++ b/docs/api.md @@ -123,7 +123,7 @@ | POST | `/api/admin/auth/logout` | | | GET | `/api/admin/auth/me` | | | GET | `/api/admin/agents` | 全部 Agent(包括 pending/disabled)、版本、本地硬上限、心跳计数、任务同步状态 | -| PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`public_visible` | +| PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`ipv6_host`、`public_visible`;`public_host_auto` / `ipv6_host_auto` 为 true 时撤销手填、回到自动识别。手填就是手动,Agent 上报的地址不再覆盖;DTO 的 `public_host_auto` / `ipv6_host_auto` 表示当前是不是自动 | | POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](enrollment.md) | | DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 | | GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次);`role` 是 `agent`(默认)或 `probe`,决定用它注册出来的是哪种节点 | diff --git a/docs/deployment.md b/docs/deployment.md index 2719bdc..5464e71 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -173,7 +173,7 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 做法和要点: - **Backend 通过隧道暴露**:隧道把一个公网域名转到家宽机器的 `29181`;Backend 监听 `http://0.0.0.0:29181`(只写一个监听地址,同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用 `fleet-enroll --server https://<公网域名>` 注册,之后用 WSS 连回 Backend。 -- **经隧道注册之后,检查 Agent 的 `public_host`**:Backend 看到的来源地址是隧道的出口,不是 VPS 的地址,要改成 VPS 的真实地址,Probe 才连得上。 +- **对外地址自动识别**:Agent 在 hello 里上报本机的公网地址(IPv4 公网地址、IPv6 全球单播地址,排除私网、CGNAT、临时地址),面板自动填 `public_host` 和 `ipv6_host`,不需要手填。经隧道注册时来源地址是隧道出口,面板不会用它。只有 Agent 在 NAT 后面、本机看不到公网地址时才需要在管理端手填;手填的优先,勾选“自动识别”可以撤销。 - **Probe 直连面板**:`probe.json` 里的 `fleet.server` 就是面板地址,Probe 用它连面板,Agent 列表由面板下发;密钥由 Probe 注册时自己生成,身份目录(`node.json`、`node.key`、`tls.pfx`)在 `fleet.identity_dir`。 - **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。 - 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。 @@ -219,7 +219,7 @@ sudo sh deploy-node.sh backend --channel dev --admin-password '<密码>' --publi sudo sh deploy-node.sh agent ``` -节点上看不到公网 IPv6 地址,所以 Agent 的 `ipv6_host` 要在管理端手动填,Backend 才会让 Probe 去拨 IPv6。下面是不用脚本的手动步骤。 +Agent 的 `public_host` 和 `ipv6_host` 由 Agent 上报自动识别(需要宿主网络,脚本和 Compose 默认如此,监听 `::`),IPv6 不用额外配置。下面是不用脚本的手动步骤。 在 `packaging/compose/agent` 下首次注册,并写配置(下列 `podman` 可替换为 `docker`): diff --git a/docs/enrollment.md b/docs/enrollment.md index 5f4cf8a..cbd45f8 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -28,7 +28,7 @@ install.sh(只做安装,不包含任何远程控制功能;容器节点的 └─ 启用 moshiro-agent.service 面板收到 OnEnrolled(MoShiro 挂钩) - ├─ public_host 未提供时,取注册请求的来源 IP + ├─ public_host 未提供时,来源 IP 是公网地址才用;之后 hello 里 Agent 上报本机公网地址(`addresses`),面板自动填 public_host 和 ipv6_host,手填的优先 ├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet) └─ 面板把新的 Agent 列表作为期望状态推给所有 Probe → Probe 自动连接新 Agent 从安装到开始出数据,全程不需要手工配置 diff --git a/docs/storage.md b/docs/storage.md index 752532c..c709eba 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -38,7 +38,7 @@ CREATE TABLE agents ( status TEXT NOT NULL, -- pending | active | disabled pubkey BLOB NOT NULL, -- ECDSA P-256 身份公钥 tls_spki BLOB NOT NULL, -- 面向 Probe 的证书指纹 - public_host TEXT NOT NULL, -- 默认取注册请求的来源 IP,可以修改 + public_host TEXT NOT NULL, -- 自动识别(Agent 上报的公网地址),手填后标记 public_host_manual,不再被覆盖 probe_tcp_port INTEGER NOT NULL, probe_udp_ports TEXT NOT NULL, labels TEXT NOT NULL DEFAULT '[]', public_visible INTEGER NOT NULL DEFAULT 1, -- 是否在 public API 中展示 diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx index a967a8c..0ebdc4c 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -15,6 +15,8 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () const [labels, setLabels] = useState(agent.labels.join(', ')) const [host, setHost] = useState(agent.public_host) const [ipv6Host, setIpv6Host] = useState(agent.ipv6_host ?? '') + const [hostAuto, setHostAuto] = useState(agent.public_host_auto) + const [ipv6Auto, setIpv6Auto] = useState(agent.ipv6_host_auto) const [visible, setVisible] = useState(agent.public_visible) return ( { const ok = await run( - () => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host.trim(), ipv6_host: ipv6Host.trim(), public_visible: visible }), + () => + api.patch(`/api/admin/agents/${agent.id}`, { + name, + labels: labels.split(',').map((x) => x.trim()).filter(Boolean), + // 自动识别的地址不提交:提交就会变成手动;从手动改回自动用 *_auto + ...(hostAuto ? (agent.public_host_auto ? {} : { public_host_auto: true }) : { public_host: host.trim() }), + ...(ipv6Auto ? (agent.ipv6_host_auto ? {} : { ipv6_host_auto: true }) : { ipv6_host: ipv6Host.trim() }), + public_visible: visible, + }), '已保存', ) if (ok) { @@ -52,13 +62,21 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
- - setHost(e.target.value)} /> + + setHost(e.target.value)} /> +
- - setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" /> + + setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" /> +
@@ -217,9 +235,13 @@ export function AgentDetail() {
ID
{a.id}
IPv4 地址
-
{a.public_host || '—'}
+
+ {a.public_host || '—'} {a.public_host_auto ? '自动' : '手动'} +
IPv6 地址
-
{a.ipv6_host || '未配置'}
+
+ {a.ipv6_host || (a.ipv6_host_auto ? '未识别到公网 IPv6 地址' : '已手动关闭')} {a.ipv6_host_auto ? '自动' : '手动'} +
IPv6 监听
{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}
IPv6 诊断
diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index f1909d5..96cfbe9 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1046,6 +1046,10 @@ export interface components { /** @default false */ ipv6_supported: boolean; ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; + /** @default true */ + public_host_auto: boolean; + /** @default true */ + ipv6_host_auto: boolean; }; AgentIpv6Dto: { agent_id: string; @@ -1059,6 +1063,8 @@ export interface components { public_host: null | string; public_visible: null | boolean; ipv6_host?: null | string; + public_host_auto?: null | boolean; + ipv6_host_auto?: null | boolean; }; ApiTokenDto: { id: string;