web: reset links to the default mode, and keep CN agents dial-only by default [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful

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.
This commit is contained in:
justme committed 2026-10-21 16:35:58 +08:00
1 parent 35207a70ea
commit 7fe9775f97
8 files changed
+222 -15

No files matched your search

+42
View File
@@ -4491,6 +4491,48 @@
"x-moshiro-capability": "fleet.read" "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}": { "/api/admin/links/{dialer_id}/{listener_id}": {
"put": { "put": {
"tags": [ "tags": [
@@ -21,6 +21,9 @@ public static class LinkEndpoints
group.MapGet("/links", ListAsync) 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<LinkMatrixDto>().ProducesProblems(401, 403) .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<LinkMatrixDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead); .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<LinkMatrixDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetWrite);
group.MapPut("/links/{dialer_id}/{listener_id}", SetAsync) 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<LinkDto>().ProducesProblems(400, 401, 403, 404, 409, 422) .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<LinkDto>().ProducesProblems(400, 401, 403, 404, 409, 422)
.Requires(Capability.FleetWrite); .Requires(Capability.FleetWrite);
@@ -29,6 +32,13 @@ public static class LinkEndpoints
.Requires(Capability.FleetWrite); .Requires(Capability.FleetWrite);
} }
private static async Task<IResult> 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<IResult> ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct) private static async Task<IResult> ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct)
{ {
LinkSnapshot snapshot = await links.SnapshotAsync(ct); LinkSnapshot snapshot = await links.SnapshotAsync(ct);
@@ -42,8 +52,8 @@ public static class LinkEndpoints
(AgentView Dialer, AgentView Listener)? pair = (AgentView Dialer, AgentView Listener)? pair =
snapshot.IsLinked(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j]) snapshot.IsLinked(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j])
: snapshot.IsLinked(nodes[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i]) : snapshot.IsLinked(nodes[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i])
: LinkSnapshot.IsDefaultDialer(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j]) : snapshot.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[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i])
: null; : null;
if (pair is { } p) if (pair is { } p)
{ {
@@ -80,6 +80,8 @@ public sealed partial class AgentService
_gateway = gateway; _gateway = gateway;
_planner = planner; _planner = planner;
_settings = settings; _settings = settings;
// 线路的默认方向看国家(国内的节点默认只拨出):国家查到或变了就重新下发
_nodes.OnCountryChanged = () => AfterAgentSetChangedAsync(CancellationToken.None);
} }
public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct) public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct)
@@ -4,11 +4,13 @@ namespace MoShiro.Backend.Application;
public sealed class LinkSnapshot public sealed class LinkSnapshot
{ {
private readonly Dictionary<(string Dialer, string Listener), bool> _overrides; private readonly Dictionary<(string Dialer, string Listener), bool> _overrides;
private readonly HashSet<string> _domestic;
public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides) public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides, HashSet<string>? domestic = null)
{ {
Auto = auto; Auto = auto;
_overrides = overrides; _overrides = overrides;
_domestic = domestic ?? [];
} }
/// <summary>自动连接:开着时,每一对节点自动连成一条线路;关着时,只有手动开启的才连。</summary> /// <summary>自动连接:开着时,每一对节点自动连成一条线路;关着时,只有手动开启的才连。</summary>
@@ -21,11 +23,25 @@ public sealed class LinkSnapshot
public bool? PairOverride(string dialerId, string listenerId) => OverrideOf(dialerId, listenerId) ?? OverrideOf(listenerId, dialerId); public bool? PairOverride(string dialerId, string listenerId) => OverrideOf(dialerId, listenerId) ?? OverrideOf(listenerId, dialerId);
/// <summary> /// <summary>
/// 自动连接时这一对由谁拨:只有一方监听,就由不监听的一方拨;两方都监听,由 id 较小的一方拨。 /// 谁更适合当被连接的一方(越大越适合):不监听的是 0;监听的是 2,但国内(国家 CN)的监听节点是 1——国内节点默认只拨出,
/// 一对节点只有一条线路、一个连接、一份 RTT;上下行都在这条连接上测,不再各拨一次。 /// 除非对端没有更好的选择。手动指定的方向不受这个影响。
/// </summary> /// </summary>
public static bool IsDefaultDialer(AgentRecord dialer, AgentRecord listener) => private int ListenerRank(AgentRecord node) => !node.Listens ? 0 : _domestic.Contains(node.Id.Value) ? 1 : 2;
listener.Listens && (!dialer.Listens || string.CompareOrdinal(dialer.Id.Value, listener.Id.Value) < 0);
/// <summary>
/// 自动连接时这一对由谁拨:更适合当被连接方的一方被拨;一样适合时 id 较小的一方拨。
/// 所以不监听的拨监听的,国内的拨国外的,其余按 id。一对节点只有一条线路、一个连接、一份 RTT;上下行都在这条连接上测。
/// </summary>
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);
}
/// <summary> /// <summary>
/// 这一对最终连不连、是不是由这个方向拨:手动设置优先(<c>true</c> 的方向就是拨号方,两个方向都设了 <c>true</c> 时按默认方向), /// 这一对最终连不连、是不是由这个方向拨:手动设置优先(<c>true</c> 的方向就是拨号方,两个方向都设了 <c>true</c> 时按默认方向),
@@ -60,7 +76,7 @@ public sealed class LinkSnapshot
} }
/// <summary> /// <summary>
/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 <see cref="LinkSnapshot.IsDefaultDialer"/>), /// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 <see cref="LinkSnapshot.IsDefaultDialer"/>,国内节点默认只拨出),
/// 关着时只连手动选中的;每一对还可以手动强制开或关,并且用 <c>true</c> 选定由哪一方拨。手动设置存在设置表里(<c>link.&lt;拨号方&gt;.&lt;监听方&gt;</c>,值 1 或 0,空表示没设置),一对节点最多保留一条。 /// 关着时只连手动选中的;每一对还可以手动强制开或关,并且用 <c>true</c> 选定由哪一方拨。手动设置存在设置表里(<c>link.&lt;拨号方&gt;.&lt;监听方&gt;</c>,值 1 或 0,空表示没设置),一对节点最多保留一条。
/// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。 /// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
/// </summary> /// </summary>
@@ -69,10 +85,12 @@ public sealed class LinkService
private const string Prefix = "link."; private const string Prefix = "link.";
private readonly ISettingsStore _settings; private readonly ISettingsStore _settings;
private readonly NodeInfoService _nodes;
public LinkService(ISettingsStore settings) public LinkService(ISettingsStore settings, NodeInfoService nodes)
{ {
_settings = settings; _settings = settings;
_nodes = nodes;
} }
public async ValueTask<LinkSnapshot> SnapshotAsync(CancellationToken ct) public async ValueTask<LinkSnapshot> SnapshotAsync(CancellationToken ct)
@@ -88,7 +106,8 @@ public sealed class LinkService
} }
} }
return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides); HashSet<string> domestic = [.. (await _nodes.ListAsync(ct)).Where(n => n.Country == "CN").Select(n => n.NodeId)];
return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides, domestic);
} }
/// <summary>手动设置一对线路:<c>true</c> 强制连并由 <paramref name="dialerId"/> 拨、<c>false</c> 强制不连、<c>null</c> 取消手动设置。反方向的旧设置同时清掉。</summary> /// <summary>手动设置一对线路:<c>true</c> 强制连并由 <paramref name="dialerId"/> 拨、<c>false</c> 强制不连、<c>null</c> 取消手动设置。反方向的旧设置同时清掉。</summary>
@@ -98,6 +117,17 @@ public sealed class LinkService
await _settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct); await _settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
} }
/// <summary>恢复默认连接模式:清掉所有手动设置并打开自动连接,一对节点谁拨谁完全按默认规则。</summary>
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);
}
/// <summary>节点被删除:清掉所有涉及它的手动设置。</summary> /// <summary>节点被删除:清掉所有涉及它的手动设置。</summary>
public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct) public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct)
{ {
@@ -29,6 +29,9 @@ public sealed class NodeInfoService
private readonly ConcurrentDictionary<string, long> _lastTry = new(); private readonly ConcurrentDictionary<string, long> _lastTry = new();
private Task? _load; private Task? _load;
/// <summary>某个节点的国家变了(第一次查到或查到了不同的结果)时调用:线路的默认方向取决于国家,需要重新下发。</summary>
public Func<ValueTask>? OnCountryChanged { get; set; }
public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time) public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time)
{ {
_store = store; _store = store;
@@ -148,15 +151,22 @@ public sealed class NodeInfoService
} }
string country = geo.Country.ToUpperInvariant(); string country = geo.Country.ToUpperInvariant();
bool countryChanged = !string.Equals(current.Info.Country, country, StringComparison.Ordinal);
if (current.Info.IspManual) if (current.Info.IspManual)
{ {
// 运营商是手动指定的:只更新国家,不覆盖运营商 // 运营商是手动指定的:只更新国家,不覆盖运营商
await SaveAsync(current with { Info = current.Info with { Country = country }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct); 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); if (countryChanged && OnCountryChanged is { } notify)
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct); {
await notify();
}
} }
finally finally
{ {
@@ -1,5 +1,7 @@
using System.Net; using System.Net;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Fleet.Server;
using MoShiro.Backend.Application;
using MoShiro.TestSupport; using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests; namespace MoShiro.Backend.Tests;
@@ -60,6 +62,56 @@ public sealed class LinkTests : IClassFixture<BackendFixture>
Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue<bool>()); Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue<bool>());
} }
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<bool>());
JsonNode link = (await LinkAsync(ids[0], ids[1]))!; // 回到默认方向:小 id 拨大 id
Assert.True(link["linked"]!.GetValue<bool>());
Assert.Null(link["override"]);
}
finally
{
await SetAutoLinkAsync(true);
}
}
[Fact] [Fact]
public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin() public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin()
{ {
+8 -2
View File
@@ -7,7 +7,7 @@ import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui'
/** /**
* 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的; * 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的;
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;都监听时 id 较小的拨)。 * 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;国内的节点默认只拨出;其余 id 较小的拨),「恢复默认连接模式」清掉所有手动设置。
*/ */
/** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */ /** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */
const CONNECTION_LABELS: Record<string, string> = { const CONNECTION_LABELS: Record<string, string> = {
@@ -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 离线,恢复后会自动重连') await run(() => api.post(`/api/admin/links/${link.dialer_id}/${link.listener_id}/retry`), '已要求 Agent 立即重试;若 Agent 离线,恢复后会自动重连')
matrix.reload() 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) => { const setAuto = async (on: boolean) => {
await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接') await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接')
matrix.reload() matrix.reload()
@@ -47,7 +52,8 @@ export function Links({ dialerId }: { dialerId?: string }) {
<b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b> <b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b>
{matrix.loading && !matrix.data ? <Spinner /> : null} {matrix.loading && !matrix.data ? <Spinner /> : null}
<span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'}</span> <span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'}</span>
{!dialerId ? <label className="switch right"> {!dialerId ? <button type="button" className="btn small right" disabled={busy} title="默认规则:不监听的节点拨监听的;国内(CN)的节点默认只拨出、不当被连接方;其余按 id 小的拨。手动设置会被清掉。" onClick={resetLinks}>恢复默认连接模式</button> : null}
{!dialerId ? <label className="switch">
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} /> <input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
自动连接 {matrix.data?.auto_link === false ? '关' : '开'} 自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
</label> : null} </label> : null}
+55
View File
@@ -1040,6 +1040,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/links/reset": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Drop every manual link setting and turn automatic linking on: who dials whom follows the default rules again */
post: operations["links_reset"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/links/{dialer_id}/{listener_id}": { "/api/admin/links/{dialer_id}/{listener_id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -5891,6 +5908,44 @@ export interface operations {
}; };
}; };
}; };
links_reset: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LinkMatrixDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
links_set: { links_set: {
parameters: { parameters: {
query?: never; query?: never;