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

@@ -290,6 +290,7 @@ public sealed class AgentApp : IDisposable
{ {
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported, Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()],
Platform = MoShiro.Measurement.PlatformDetector.Detect(), Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsSpki = _identity.File.TlsSpki, TlsSpki = _identity.File.TlsSpki,
ProbeTcpPort = ControlPort, ProbeTcpPort = ControlPort,
+20
View File
@@ -4394,6 +4394,14 @@
"$ref": "#/components/schemas/Ipv6DiagnosticDto" "$ref": "#/components/schemas/Ipv6DiagnosticDto"
} }
] ]
},
"public_host_auto": {
"type": "boolean",
"default": true
},
"ipv6_host_auto": {
"type": "boolean",
"default": true
} }
} }
}, },
@@ -4461,6 +4469,18 @@
"null", "null",
"string" "string"
] ]
},
"public_host_auto": {
"type": [
"null",
"boolean"
]
},
"ipv6_host_auto": {
"type": [
"null",
"boolean"
]
} }
} }
}, },
@@ -0,0 +1,106 @@
using System.Globalization;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 本机的公网地址:Agent 注册之后在 hello 里上报,面板用它自动填 <c>public_host</c> 和 <c>ipv6_host</c>,不需要管理员手填。
/// 只列出「别人能拨进来」的地址:IPv4 要排除私网、CGNAT、回环、链路本地;IPv6 只要全球单播(2000::/3),
/// 并且排除临时地址、tentative、deprecated 和文档/6to4/Teredo 段。读不到网卡信息时返回空,面板退回到管理员手填或观察到的来源地址。
/// </summary>
public static class LocalAddresses
{
public static IReadOnlyList<string> Sample()
{
var result = new List<string>();
HashSet<string> 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;
}
/// <summary>Linux 的 <c>/proc/net/if_inet6</c>:临时(0x01)、dadfailed(0x08)、deprecated(0x20)、tentative(0x40)的地址不能当作对外地址。</summary>
public static HashSet<string> UnusableIpv6(string? procNetIfInet6)
{
var result = new HashSet<string>(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;
}
}
}
@@ -0,0 +1,46 @@
using System.Net;
using System.Net.Sockets;
namespace MoShiro.Protocol;
/// <summary>哪些地址算「公网上能拨进来的地址」:Agent 上报本机地址和面板自动识别共用同一套判断。</summary>
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));
}
}
@@ -326,6 +326,10 @@ public sealed class AgentHelloMeta
[JsonPropertyName("tls_spki")] [JsonPropertyName("tls_spki")]
public string? TlsSpki { get; set; } public string? TlsSpki { get; set; }
/// <summary>本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 <c>public_host</c> 和 <c>ipv6_host</c>,管理员手填的优先。</summary>
[JsonPropertyName("addresses")]
public List<string>? Addresses { get; set; }
[JsonPropertyName("probe_tcp_port")] [JsonPropertyName("probe_tcp_port")]
public int? ProbeTcpPort { get; set; } public int? ProbeTcpPort { get; set; }
@@ -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<string> 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));
}
}
}
@@ -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)));
}
@@ -81,7 +81,7 @@ public static class AgentEndpoints
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct) private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
{ {
Result<AgentView> result = await agents.PatchAsync( 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)) if (!result.TryGet(out AgentView? view, out FleetError? error))
{ {
return Problems.From(http, error); return Problems.From(http, error);
@@ -103,7 +103,7 @@ public static class Mappers
: null; : null;
return new AgentDto( 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.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) public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
@@ -40,13 +40,16 @@ public sealed record AgentDto(
DateTimeOffset? LastSeenAt, DateTimeOffset? LastSeenAt,
string Ipv6Host = "", string Ipv6Host = "",
bool Ipv6Supported = false, 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, 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); string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary> /// <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> /// <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"); 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 '-'"; 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"; 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"; 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); 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); await AfterAgentSetChangedAsync(ct);
} }
@@ -40,9 +40,16 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
} }
} }
// public_host 未提供时,取注册请求的来源 IP // 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : context.RemoteIp ?? ""; 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); 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) if (node.Status == NodeStatus.Active)
{ {
await _service.AfterAgentSetChangedAsync(ct); await _service.AfterAgentSetChangedAsync(ct);
@@ -218,15 +225,22 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
AgentRecord? before = await _agents.GetAsync(id, ct); AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits); 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); 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); AgentRecord? after = await _agents.GetAsync(id, ct);
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform); _nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
await _releases.OfferToNodeAsync(id, ct); await _releases.OfferToNodeAsync(id, ct);
await _schedule.SendToNodeAsync(id, ct); await _schedule.SendToNodeAsync(id, ct);
if (before is not null && after is not null 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) && after.Status == NodeStatus.Active)
{ {
await _service.AfterAgentSetChangedAsync(ct); await _service.AfterAgentSetChangedAsync(ct);
@@ -25,7 +25,10 @@ public sealed record AgentRecord(
long RowVersion, long RowVersion,
string Ipv6Host = "", string Ipv6Host = "",
bool Ipv6Supported = false, bool Ipv6Supported = false,
string? Ipv6DiagnosticsJson = null); string? Ipv6DiagnosticsJson = null,
bool PublicHostManual = false,
bool Ipv6HostManual = false,
IReadOnlyList<string>? ReportedAddresses = null);
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary> /// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
public sealed class AgentPatch public sealed class AgentPatch
@@ -38,6 +41,11 @@ public sealed class AgentPatch
public string? Ipv6Host { get; init; } 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; } public bool? PublicVisible { get; init; }
} }
@@ -50,7 +58,8 @@ public sealed record AgentHello(
int? ProbeTcpPort, int? ProbeTcpPort,
IReadOnlyList<int>? ProbeUdpPorts, IReadOnlyList<int>? ProbeUdpPorts,
bool Ipv6Supported = false, bool Ipv6Supported = false,
string? Ipv6DiagnosticsJson = null); string? Ipv6DiagnosticsJson = null,
IReadOnlyList<string>? Addresses = null);
/// <summary>注册之后 MoShiro 的补充:公开地址和端口。</summary> /// <summary>注册之后 MoShiro 的补充:公开地址和端口。</summary>
public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList<int> ProbeUdpPorts, IReadOnlyList<string> Kinds); 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); 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 SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct);
ValueTask<string?> GetTaskHashAsync(NodeId id, 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> /// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
internal static class Schema internal static class Schema
{ {
public const int CurrentVersion = 8; public const int CurrentVersion = 9;
public static void Migrate(SqliteConnection connection) public static void Migrate(SqliteConnection connection)
{ {
@@ -120,6 +120,17 @@ internal static class Schema
connection.Exec("PRAGMA user_version = 8"); connection.Exec("PRAGMA user_version = 8");
transaction.Commit(); 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> /// <summary>
@@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
{ {
private const string AgentColumns = 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, " + "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; 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"); 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( 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), ("@id", id.Value), ("@name", patch.Name ?? current.Name),
("@labels", StorageJson.ToJson(patch.Labels ?? current.Labels)), ("@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)!); return Result<AgentRecord>.Ok(GetInternal(c, id)!);
}, ct); }, ct);
@@ -117,9 +124,21 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
{ {
c.Exec( c.Exec(
"UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), " + "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), ("@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); }, ct);
public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken 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.GetInt64(18),
r.GetString(19), r.GetString(19),
r.GetInt64(20) != 0, 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) => 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); new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs);
@@ -0,0 +1,90 @@
using System.Net;
using System.Text.Json.Nodes;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>public_host 和 ipv6_host 自动识别:Agent 上报公网地址,手填的优先,可以撤销手填。</summary>
[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<JsonNode> AgentAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + id);
private async Task<JsonNode> 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<string>() == V6);
Assert.Equal(V4, view["public_host"]!.GetValue<string>());
Assert.True(view["public_host_auto"]!.GetValue<bool>());
Assert.True(view["ipv6_host_auto"]!.GetValue<bool>());
}
[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<string>() == V6);
// 手填:标记为手动,之后 Agent 再上报别的地址也不覆盖
JsonNode manual = await PatchAsync(id, new JsonObject { ["public_host"] = "agent.example.net", ["ipv6_host"] = "" });
Assert.False(manual["public_host_auto"]!.GetValue<bool>());
Assert.False(manual["ipv6_host_auto"]!.GetValue<bool>());
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<string>());
Assert.Equal("", kept["ipv6_host"]!.GetValue<string>()); // 空 = 手动关闭 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<string>());
Assert.Equal("2606:4700:4700::1001", auto["ipv6_host"]!.GetValue<string>());
Assert.True(auto["public_host_auto"]!.GetValue<bool>());
}
[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<string>() == V4);
agent.Addresses = ["1.0.0.1", V4];
await agent.HelloAsync();
await Task.Delay(300);
Assert.Equal(V4, (await AgentAsync(id))["public_host"]!.GetValue<string>());
// 原来的地址不见了才换
agent.Addresses = ["1.0.0.1"];
await agent.HelloAsync();
await BackendFixture.EventuallyAsync(async () => (await AgentAsync(id))["public_host"]!.GetValue<string>() == "1.0.0.1");
}
}
@@ -49,6 +49,10 @@ public sealed class AgentHarness : IAsyncDisposable
Result<EnrollmentResult> enrolled = await EnrollmentClient.EnrollAsync(http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None); Result<EnrollmentResult> 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()); 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 var config = new AgentConfig
{ {
Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory }, Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory },
@@ -139,12 +139,16 @@ public sealed class AgentSimulator : IAsyncDisposable
} }
} }
/// <summary>hello 里上报的本机公网地址(缺省不上报)。</summary>
public string[]? Addresses { get; set; }
public async Task HelloAsync(string[]? kinds = null) public async Task HelloAsync(string[]? kinds = null)
{ {
var meta = JsonSerializer.SerializeToElement( var meta = JsonSerializer.SerializeToElement(
new AgentHelloMeta new AgentHelloMeta
{ {
TlsSpki = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), TlsSpki = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
Addresses = Addresses is null ? null : [.. Addresses],
ProbeTcpPort = 7443, ProbeTcpPort = 7443,
ProbeUdpPorts = [7443], ProbeUdpPorts = [7443],
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 }, LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
+1 -1
View File
@@ -123,7 +123,7 @@
| POST | `/api/admin/auth/logout` | | | POST | `/api/admin/auth/logout` | |
| GET | `/api/admin/auth/me` | | | GET | `/api/admin/auth/me` | |
| GET | `/api/admin/agents` | 全部 Agent(包括 pending/disabled)、版本、本地硬上限、心跳计数、任务同步状态 | | 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) | | POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](enrollment.md) |
| DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 | | DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 |
| GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次);`role` 是 `agent`(默认)或 `probe`,决定用它注册出来的是哪种节点 | | GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次);`role` 是 `agent`(默认)或 `probe`,决定用它注册出来的是哪种节点 |
+2 -2
View File
@@ -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。 - **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`。 - **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/…`、停掉进程即可,没有留下任何东西。 - **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。
- 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。 - 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。
@@ -219,7 +219,7 @@ sudo sh deploy-node.sh backend --channel dev --admin-password '<密码>' --publi
sudo sh deploy-node.sh agent 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`): 在 `packaging/compose/agent` 下首次注册,并写配置(下列 `podman` 可替换为 `docker`):
+1 -1
View File
@@ -28,7 +28,7 @@ install.sh(只做安装,不包含任何远程控制功能;容器节点的
└─ 启用 moshiro-agent.service └─ 启用 moshiro-agent.service
面板收到 OnEnrolled(MoShiro 挂钩) 面板收到 OnEnrolled(MoShiro 挂钩)
├─ public_host 未提供时,取注册请求的来源 IP ├─ public_host 未提供时,来源 IP 是公网地址才用;之后 hello 里 Agent 上报本机公网地址(`addresses`),面板自动填 public_host 和 ipv6_host,手填的优先
├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet) ├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet)
└─ 面板把新的 Agent 列表作为期望状态推给所有 Probe → Probe 自动连接新 Agent └─ 面板把新的 Agent 列表作为期望状态推给所有 Probe → Probe 自动连接新 Agent
从安装到开始出数据,全程不需要手工配置 从安装到开始出数据,全程不需要手工配置
+1 -1
View File
@@ -38,7 +38,7 @@ CREATE TABLE agents (
status TEXT NOT NULL, -- pending | active | disabled status TEXT NOT NULL, -- pending | active | disabled
pubkey BLOB NOT NULL, -- ECDSA P-256 身份公钥 pubkey BLOB NOT NULL, -- ECDSA P-256 身份公钥
tls_spki BLOB NOT NULL, -- 面向 Probe 的证书指纹 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, probe_tcp_port INTEGER NOT NULL, probe_udp_ports TEXT NOT NULL,
labels TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]',
public_visible INTEGER NOT NULL DEFAULT 1, -- 是否在 public API 中展示 public_visible INTEGER NOT NULL DEFAULT 1, -- 是否在 public API 中展示
+29 -7
View File
@@ -15,6 +15,8 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
const [labels, setLabels] = useState(agent.labels.join(', ')) const [labels, setLabels] = useState(agent.labels.join(', '))
const [host, setHost] = useState(agent.public_host) const [host, setHost] = useState(agent.public_host)
const [ipv6Host, setIpv6Host] = useState(agent.ipv6_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) const [visible, setVisible] = useState(agent.public_visible)
return ( return (
<Dialog <Dialog
@@ -31,7 +33,15 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
disabled={busy} disabled={busy}
onClick={async () => { onClick={async () => {
const ok = await run( 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) { if (ok) {
@@ -52,13 +62,21 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
</Field> </Field>
</div> </div>
<div className="wide"> <div className="wide">
<Field label="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)"> <Field label="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)。默认由 Agent 上报自动识别">
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} /> <input className="input" value={host} disabled={hostAuto} onChange={(e) => setHost(e.target.value)} />
<label className="switch">
<input type="checkbox" checked={hostAuto} onChange={(e) => setHostAuto(e.target.checked)} />
自动识别
</label>
</Field> </Field>
</div> </div>
<div className="wide"> <div className="wide">
<Field label="IPv6 地址" hint="IPv6 地址或域名(AAAA 记录);留空只采集 IPv4"> <Field label="IPv6 地址" hint="IPv6 地址或域名(AAAA 记录)。默认由 Agent 上报自动识别;手动时留空表示只采集 IPv4">
<input className="input" value={ipv6Host} onChange={(e) => setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" /> <input className="input" value={ipv6Host} disabled={ipv6Auto} onChange={(e) => setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" />
<label className="switch">
<input type="checkbox" checked={ipv6Auto} onChange={(e) => setIpv6Auto(e.target.checked)} />
自动识别
</label>
</Field> </Field>
</div> </div>
<div className="wide"> <div className="wide">
@@ -217,9 +235,13 @@ export function AgentDetail() {
<dt>ID</dt> <dt>ID</dt>
<dd className="mono">{a.id}</dd> <dd className="mono">{a.id}</dd>
<dt>IPv4 地址</dt> <dt>IPv4 地址</dt>
<dd className="mono">{a.public_host || '—'}</dd> <dd className="mono">
{a.public_host || '—'} <Chip>{a.public_host_auto ? '自动' : '手动'}</Chip>
</dd>
<dt>IPv6 地址</dt> <dt>IPv6 地址</dt>
<dd className="mono">{a.ipv6_host || '未配置'}</dd> <dd className="mono">
{a.ipv6_host || (a.ipv6_host_auto ? '未识别到公网 IPv6 地址' : '已手动关闭')} <Chip>{a.ipv6_host_auto ? '自动' : '手动'}</Chip>
</dd>
<dt>IPv6 监听</dt> <dt>IPv6 监听</dt>
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd> <dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
<dt>IPv6 诊断</dt> <dt>IPv6 诊断</dt>
+6
View File
@@ -1046,6 +1046,10 @@ export interface components {
/** @default false */ /** @default false */
ipv6_supported: boolean; ipv6_supported: boolean;
ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"];
/** @default true */
public_host_auto: boolean;
/** @default true */
ipv6_host_auto: boolean;
}; };
AgentIpv6Dto: { AgentIpv6Dto: {
agent_id: string; agent_id: string;
@@ -1059,6 +1063,8 @@ export interface components {
public_host: null | string; public_host: null | string;
public_visible: null | boolean; public_visible: null | boolean;
ipv6_host?: null | string; ipv6_host?: null | string;
public_host_auto?: null | boolean;
ipv6_host_auto?: null | boolean;
}; };
ApiTokenDto: { ApiTokenDto: {
id: string; id: string;