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")),
Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()],
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsSpki = _identity.File.TlsSpki,
ProbeTcpPort = ControlPort,
+20
View File
@@ -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
View File
@@ -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
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。
- **经隧道注册之后,检查 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
View File
@@ -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
View File
@@ -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 中展示
+29 -7
View File
@@ -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>
+6
View File
@@ -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;