From 7fe9775f97ef6fa839ff417d0e778146e2bfe193 Mon Sep 17 00:00:00 2001 From: justme Date: Wed, 21 Oct 2026 16:35:27 +0800 Subject: [PATCH] web: reset links to the default mode, and keep CN agents dial-only by default [CI] POST /api/admin/links/reset clears every manual link setting and turns automatic linking on. In the default rules a listening node in CN ranks below a listening node elsewhere, so the CN side dials; the links are pushed again when a node's country is first resolved or changes. --- backend/openapi/v1.json | 42 ++++++++++++++ .../Admin/LinkEndpoints.cs | 14 ++++- .../Agents/AgentService.cs | 2 + .../Agents/LinkService.cs | 46 +++++++++++++--- .../Nodes/NodeInfoService.cs | 16 +++++- .../tests/MoShiro.Backend.Tests/LinkTests.cs | 52 ++++++++++++++++++ web/admin/src/pages/Links.tsx | 10 +++- web/shared/api/schema.ts | 55 +++++++++++++++++++ 8 files changed, 222 insertions(+), 15 deletions(-) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index f692602..bd3c0c4 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4491,6 +4491,48 @@ "x-moshiro-capability": "fleet.read" } }, + "/api/admin/links/reset": { + "post": { + "tags": [ + "admin-links" + ], + "summary": "Drop every manual link setting and turn automatic linking on: who dials whom follows the default rules again", + "operationId": "links_reset", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkMatrixDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "fleet.write" + } + }, "/api/admin/links/{dialer_id}/{listener_id}": { "put": { "tags": [ diff --git a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs index 2ecea30..a5691f6 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs @@ -21,6 +21,9 @@ public static class LinkEndpoints group.MapGet("/links", ListAsync) .Api("links_list", "Every possible dialer/listener pair: whether it is linked, the manual override and whether it is connected; plus the nodes and which of them listen", Tag).Returns().ProducesProblems(401, 403) .Requires(Capability.FleetRead); + group.MapPost("/links/reset", ResetAsync) + .Api("links_reset", "Drop every manual link setting and turn automatic linking on: who dials whom follows the default rules again", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.FleetWrite); group.MapPut("/links/{dialer_id}/{listener_id}", SetAsync) .Api("links_set", "Connect a node to a listening node (enabled: true), force the link off (false), or give it back to the automatic setting (null)", Tag).Returns().ProducesProblems(400, 401, 403, 404, 409, 422) .Requires(Capability.FleetWrite); @@ -29,6 +32,13 @@ public static class LinkEndpoints .Requires(Capability.FleetWrite); } + private static async Task ResetAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct) + { + await links.ResetAsync(ct); + await agents.AfterAgentSetChangedAsync(ct); + return await ListAsync(agents, links, runtime, time, ct); + } + private static async Task ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct) { LinkSnapshot snapshot = await links.SnapshotAsync(ct); @@ -42,8 +52,8 @@ public static class LinkEndpoints (AgentView Dialer, AgentView Listener)? pair = snapshot.IsLinked(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j]) : snapshot.IsLinked(nodes[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i]) - : LinkSnapshot.IsDefaultDialer(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j]) - : LinkSnapshot.IsDefaultDialer(nodes[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i]) + : snapshot.IsDefaultDialer(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j]) + : snapshot.IsDefaultDialer(nodes[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i]) : null; if (pair is { } p) { diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index 9da2872..0b2a1ff 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -80,6 +80,8 @@ public sealed partial class AgentService _gateway = gateway; _planner = planner; _settings = settings; + // 线路的默认方向看国家(国内的节点默认只拨出):国家查到或变了就重新下发 + _nodes.OnCountryChanged = () => AfterAgentSetChangedAsync(CancellationToken.None); } public async ValueTask> ListAsync(CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs b/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs index f261b1f..37812e5 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs @@ -4,11 +4,13 @@ namespace MoShiro.Backend.Application; public sealed class LinkSnapshot { private readonly Dictionary<(string Dialer, string Listener), bool> _overrides; + private readonly HashSet _domestic; - public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides) + public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides, HashSet? domestic = null) { Auto = auto; _overrides = overrides; + _domestic = domestic ?? []; } /// 自动连接:开着时,每一对节点自动连成一条线路;关着时,只有手动开启的才连。 @@ -21,11 +23,25 @@ public sealed class LinkSnapshot public bool? PairOverride(string dialerId, string listenerId) => OverrideOf(dialerId, listenerId) ?? OverrideOf(listenerId, dialerId); /// - /// 自动连接时这一对由谁拨:只有一方监听,就由不监听的一方拨;两方都监听,由 id 较小的一方拨。 - /// 一对节点只有一条线路、一个连接、一份 RTT;上下行都在这条连接上测,不再各拨一次。 + /// 谁更适合当被连接的一方(越大越适合):不监听的是 0;监听的是 2,但国内(国家 CN)的监听节点是 1——国内节点默认只拨出, + /// 除非对端没有更好的选择。手动指定的方向不受这个影响。 /// - public static bool IsDefaultDialer(AgentRecord dialer, AgentRecord listener) => - listener.Listens && (!dialer.Listens || string.CompareOrdinal(dialer.Id.Value, listener.Id.Value) < 0); + private int ListenerRank(AgentRecord node) => !node.Listens ? 0 : _domestic.Contains(node.Id.Value) ? 1 : 2; + + /// + /// 自动连接时这一对由谁拨:更适合当被连接方的一方被拨;一样适合时 id 较小的一方拨。 + /// 所以不监听的拨监听的,国内的拨国外的,其余按 id。一对节点只有一条线路、一个连接、一份 RTT;上下行都在这条连接上测。 + /// + public bool IsDefaultDialer(AgentRecord dialer, AgentRecord listener) + { + if (!listener.Listens) + { + return false; + } + + int rank = ListenerRank(listener) - ListenerRank(dialer); + return rank > 0 || (rank == 0 && string.CompareOrdinal(dialer.Id.Value, listener.Id.Value) < 0); + } /// /// 这一对最终连不连、是不是由这个方向拨:手动设置优先(true 的方向就是拨号方,两个方向都设了 true 时按默认方向), @@ -60,7 +76,7 @@ public sealed class LinkSnapshot } /// -/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 ), +/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 ,国内节点默认只拨出), /// 关着时只连手动选中的;每一对还可以手动强制开或关,并且用 true 选定由哪一方拨。手动设置存在设置表里(link.<拨号方>.<监听方>,值 1 或 0,空表示没设置),一对节点最多保留一条。 /// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(peers)和线路目录里;改动之后要调用 重新下发。 /// @@ -69,10 +85,12 @@ public sealed class LinkService private const string Prefix = "link."; private readonly ISettingsStore _settings; + private readonly NodeInfoService _nodes; - public LinkService(ISettingsStore settings) + public LinkService(ISettingsStore settings, NodeInfoService nodes) { _settings = settings; + _nodes = nodes; } public async ValueTask SnapshotAsync(CancellationToken ct) @@ -88,7 +106,8 @@ public sealed class LinkService } } - return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides); + HashSet domestic = [.. (await _nodes.ListAsync(ct)).Where(n => n.Country == "CN").Select(n => n.NodeId)]; + return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides, domestic); } /// 手动设置一对线路:true 强制连并由 拨、false 强制不连、null 取消手动设置。反方向的旧设置同时清掉。 @@ -98,6 +117,17 @@ public sealed class LinkService await _settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct); } + /// 恢复默认连接模式:清掉所有手动设置并打开自动连接,一对节点谁拨谁完全按默认规则。 + public async ValueTask ResetAsync(CancellationToken ct) + { + foreach (string key in (await _settings.GetAllAsync(ct)).Where(kv => kv.Key.StartsWith(Prefix, StringComparison.Ordinal) && kv.Value.Length > 0).Select(kv => kv.Key)) + { + await _settings.SetAsync(key, "", ct); + } + + await _settings.SetAsync(SettingsService.AutoLink, "true", ct); + } + /// 节点被删除:清掉所有涉及它的手动设置。 public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct) { diff --git a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs index 6f230f7..47d9390 100644 --- a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs +++ b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs @@ -29,6 +29,9 @@ public sealed class NodeInfoService private readonly ConcurrentDictionary _lastTry = new(); private Task? _load; + /// 某个节点的国家变了(第一次查到或查到了不同的结果)时调用:线路的默认方向取决于国家,需要重新下发。 + public Func? OnCountryChanged { get; set; } + public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time) { _store = store; @@ -148,15 +151,22 @@ public sealed class NodeInfoService } string country = geo.Country.ToUpperInvariant(); + bool countryChanged = !string.Equals(current.Info.Country, country, StringComparison.Ordinal); if (current.Info.IspManual) { // 运营商是手动指定的:只更新国家,不覆盖运营商 await SaveAsync(current with { Info = current.Info with { Country = country }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct); - return; + } + else + { + (string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization); + await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct); } - (string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization); - await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct); + if (countryChanged && OnCountryChanged is { } notify) + { + await notify(); + } } finally { diff --git a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs index 5b4d846..4fc35a7 100644 --- a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs @@ -1,5 +1,7 @@ using System.Net; using System.Text.Json.Nodes; +using Fleet.Server; +using MoShiro.Backend.Application; using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; @@ -60,6 +62,56 @@ public sealed class LinkTests : IClassFixture Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue()); } + private static AgentRecord Node(string id, bool listens) => + new(new Fleet.Core.NodeId(id), id, NodeStatus.Active, [], [], "", 0, [], [], true, [], null, null, 0, 0, null, 0, null, 0, Listens: listens); + + [Fact] + public void DefaultDialer_NonListeningDials_ThenDomesticDialsForeign_ThenSmallerId() + { + AgentRecord home = Node("agt_a", listens: false); + AgentRecord cn = Node("agt_b", listens: true); + AgentRecord abroad = Node("agt_c", listens: true); + AgentRecord abroad2 = Node("agt_d", listens: true); + var snapshot = new LinkSnapshot(true, [], ["agt_b"]); + + Assert.True(snapshot.IsLinked(home, cn)); // 不监听的拨监听的 + Assert.False(snapshot.IsLinked(cn, home)); + Assert.True(snapshot.IsLinked(cn, abroad)); // 国内的拨国外的,哪怕它的 id 更小 + Assert.False(snapshot.IsLinked(abroad, cn)); + Assert.True(snapshot.IsLinked(abroad, abroad2)); // 一样的按 id,小的拨 + Assert.False(snapshot.IsLinked(abroad2, abroad)); + Assert.True(snapshot.IsLinked(home, cn)); // 只有国内的在监听时,国内的也得当监听方 + + // 手动指定的方向不受国家影响:让国外的拨国内的 + var manual = new LinkSnapshot(true, new() { [("agt_c", "agt_b")] = true }, ["agt_b"]); + Assert.True(manual.IsLinked(abroad, cn)); + Assert.False(manual.IsLinked(cn, abroad)); + } + + [Fact] + public async Task Links_ResetDropsManualSettingsAndTurnsAutomaticLinkingOn() + { + await using AgentSimulator first = await _backend.ConnectAgentAsync(); + await using AgentSimulator second = await _backend.ConnectAgentAsync(); + string[] ids = [.. new[] { first.NodeId.Value, second.NodeId.Value }.Order(StringComparer.Ordinal)]; + await SetLinkAsync(ids[1], ids[0], true); // 手动让大 id 的拨小 id 的 + await SetAutoLinkAsync(false); + try + { + using HttpResponseMessage reset = await _backend.Admin.PostAsync("/api/admin/links/reset", BackendFixture.Json(new JsonObject())); + reset.EnsureSuccessStatusCode(); + JsonNode matrix = JsonNode.Parse(await reset.Content.ReadAsStringAsync())!; + Assert.True(matrix["auto_link"]!.GetValue()); + JsonNode link = (await LinkAsync(ids[0], ids[1]))!; // 回到默认方向:小 id 拨大 id + Assert.True(link["linked"]!.GetValue()); + Assert.Null(link["override"]); + } + finally + { + await SetAutoLinkAsync(true); + } + } + [Fact] public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin() { diff --git a/web/admin/src/pages/Links.tsx b/web/admin/src/pages/Links.tsx index 3c97f4e..0e2b868 100644 --- a/web/admin/src/pages/Links.tsx +++ b/web/admin/src/pages/Links.tsx @@ -7,7 +7,7 @@ import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui' /** * 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的; - * 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;都监听时 id 较小的拨)。 + * 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;国内的节点默认只拨出;其余 id 较小的拨),「恢复默认连接模式」清掉所有手动设置。 */ /** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */ const CONNECTION_LABELS: Record = { @@ -36,6 +36,11 @@ export function Links({ dialerId }: { dialerId?: string }) { await run(() => api.post(`/api/admin/links/${link.dialer_id}/${link.listener_id}/retry`), '已要求 Agent 立即重试;若 Agent 离线,恢复后会自动重连') matrix.reload() } + const resetLinks = async () => { + if (!window.confirm('清掉所有手动设置并打开自动连接,每对 Agent 按默认规则重新连线?')) return + await run(() => api.post('/api/admin/links/reset'), '已恢复默认连接模式') + matrix.reload() + } const setAuto = async (on: boolean) => { await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接') matrix.reload() @@ -47,7 +52,8 @@ export function Links({ dialerId }: { dialerId?: string }) { {dialerId ? '拨出连接' : 'Agent 节点画板'} {matrix.loading && !matrix.data ? : null} {dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'} - {!dialerId ?