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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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")]
|
||||
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")]
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
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 },
|
||||
|
||||
@@ -139,12 +139,16 @@ public sealed class AgentSimulator : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>hello 里上报的本机公网地址(缺省不上报)。</summary>
|
||||
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 },
|
||||
|
||||
+1
-1
@@ -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`,决定用它注册出来的是哪种节点 |
|
||||
|
||||
+2
-2
@@ -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`):
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
从安装到开始出数据,全程不需要手工配置
|
||||
|
||||
+1
-1
@@ -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 中展示
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
@@ -31,7 +33,15 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
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: ()
|
||||
</Field>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)">
|
||||
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} />
|
||||
<Field label="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)。默认由 Agent 上报自动识别">
|
||||
<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>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="IPv6 地址" hint="IPv6 地址或域名(AAAA 记录);留空只采集 IPv4">
|
||||
<input className="input" value={ipv6Host} onChange={(e) => setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" />
|
||||
<Field label="IPv6 地址" hint="IPv6 地址或域名(AAAA 记录)。默认由 Agent 上报自动识别;手动时留空表示只采集 IPv4">
|
||||
<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>
|
||||
</div>
|
||||
<div className="wide">
|
||||
@@ -217,9 +235,13 @@ export function AgentDetail() {
|
||||
<dt>ID</dt>
|
||||
<dd className="mono">{a.id}</dd>
|
||||
<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>
|
||||
<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>
|
||||
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
|
||||
<dt>IPv6 诊断</dt>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user