From c76af43b7c4ac9cc946d910d9a6e17300433d303 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 21 Sep 2026 16:49:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=87=E6=9C=BA=E6=96=B0=E9=B2=9C=E5=BA=A6?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E3=80=8C=E4=B8=BB=E7=AB=99=E4=BB=BB=E4=BD=95?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E3=80=8D=E5=88=A4=E5=AE=9A=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BE=9D=E8=B5=96=E5=BF=AB=E7=85=A7=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:过期语义是「主站失联」,但节点手里的时间戳是「最近一次收到快照」。 快照是内容寻址的,内容长期不变时主站没有理由重发整份快照;若以收到快照为准, 备机会在内容不动的第 7 天误判过期。此前在主站侧加的「低频保活推送」正是为了 绕过这个缺陷——它让主站为避免节点过期而周期性传输约 346 KiB 的快照。 改为在节点侧接受更本质的存活信号: - SubscriptionSnapshotStore.lastSyncAt -> lastPrimaryContactAt,并新增 markPrimaryContact();isStale() 依据它判断,语义变为 「过期 ⇔ 主站失联超过 SubscriptionMaxStaleSeconds」。 - storageNode 在收到主站通道的任何消息时刷新该时刻,不限于订阅快照。 主站本就有每 30 分钟的 AvailableCheckMessage,对 7 天有效期有 300 余倍余量, 因此不再需要任何为「续期」而生的专用推送。 - 重启仍以落盘内容的生成时间为起点,避免重启即续期。 测试:15 项全过。新增「主站存活信号可在无新快照时保持订阅新鲜」与 「主站失联后仍必须判过期」两条,锁住新语义的两个方向。 --- .../Service/SubscriptionSnapshotStore.java | 46 +++++++++++++------ src/main/java/lion/storageNode.java | 6 +++ .../SubscriptionSnapshotStoreTest.java | 37 +++++++++++++++ 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/main/java/lion/Service/SubscriptionSnapshotStore.java b/src/main/java/lion/Service/SubscriptionSnapshotStore.java index b84c813..2baf2fc 100644 --- a/src/main/java/lion/Service/SubscriptionSnapshotStore.java +++ b/src/main/java/lion/Service/SubscriptionSnapshotStore.java @@ -39,17 +39,20 @@ public final class SubscriptionSnapshotStore { private final ObjectMapper objectMapper = CustomUtil.objectMapper; private final AtomicReference current = new AtomicReference<>(); /** - * 最近一次收到并校验通过的主站快照的时刻(本地接收时间)。 + * 最近一次收到主站存活信号的时刻(本地接收时间)。 * - *

过期判定必须基于它而不是内容里的 {@code generatedAt}:revision 是内容寻址的, - * 内容不变时主站每分钟重发同一 revision、{@code generatedAt} 却一直在刷新, - * 节点按 APPLY_OLD 丢弃后新鲜度永远停在首次接收那天——于是主站明明在线且持续同步, - * 备机也会在第 7 天被判过期并开始返回 503。改用本地接收时间后,「过期」才真正 - * 表示「主站已失联」,与设计文档一致。 + *

「过期」的语义是「主站已失联」,因此新鲜度必须由「主站的任何存活信号」驱动, + * 而不能只看「收到快照」。快照是内容寻址的:内容长期不变时主站没有理由反复推送, + * 若以收到快照为准,备机会在内容不动的第 7 天误判过期;反过来,若主站为此定时重发 + * 整份快照,又只为续期而传输约 346 KiB/次。 * - *

接收时间取自节点本地时钟、不参与签名,因此攻击者无法通过重放旧 revision 续期。 + *

主站本就有常规存活探测(每 30 分钟的 AvailableCheckMessage),节点回它以 + * ResponseMessage。把该信号纳入新鲜度后:过期 ⇔ 主站失联超过有效期, + * 语义精确,且不再需要任何为「续期」而生的专用推送。 + * + *

时刻取自节点本地时钟、不参与签名,攻击者无法通过重放旧 revision 续期。 */ - private volatile long lastSyncAt; + private volatile long lastPrimaryContactAt; public SubscriptionSnapshotStore(Path root, String syncSecret, long maxStaleSeconds, int maxPayloadBytes) { this.root = Objects.requireNonNull(root); @@ -86,7 +89,7 @@ public final class SubscriptionSnapshotStore { current.set(snapshot); // 重启后还没有收到过主站快照,先以落盘内容的生成时间作为新鲜度起点, // 否则重启即视为「刚同步过」,会让超期快照被错误续期。 - lastSyncAt = snapshot.generatedAt(); + lastPrimaryContactAt = snapshot.generatedAt(); writePointer(revision); log.info("加载订阅快照成功 revision={} accounts={} bindings={}", shortRevision(revision), snapshot.accountCount(), snapshot.bindingCount()); return; @@ -123,7 +126,7 @@ public final class SubscriptionSnapshotStore { if (old != null) { if (message.getRevision().equals(old.revision())) { // 内容未变但主站仍在同步:刷新新鲜度,避免备机因「内容长期不变」而误判过期。 - lastSyncAt = System.currentTimeMillis(); + lastPrimaryContactAt = System.currentTimeMillis(); return new ApplyResult(APPLY_OLD, "revision 已存在"); } if (message.getGeneratedAt() < old.generatedAt()) @@ -157,7 +160,7 @@ public final class SubscriptionSnapshotStore { Snapshot snapshot = new Snapshot(message.getRevision(), message.getGeneratedAt(), data.byKeyHash, data.accounts.size(), data.bindingCount); current.set(snapshot); - lastSyncAt = System.currentTimeMillis(); + lastPrimaryContactAt = System.currentTimeMillis(); cleanupOldSnapshots(message.getRevision()); return new ApplyResult(APPLY_SUCCESS, "同步成功"); } catch (Exception e) { @@ -194,13 +197,28 @@ public final class SubscriptionSnapshotStore { if (snapshot == null) return new Status("unavailable", null, 0, 0, 0); long now = System.currentTimeMillis(); - long age = Math.max(0, now - lastSyncAt); + long age = Math.max(0, now - lastPrimaryContactAt); return new Status(isStale(now) ? "expired" : "ready", snapshot.revision(), snapshot.accountCount(), snapshot.bindingCount(), age); } - /** 距最近一次成功接收主站快照是否已超过最大有效期。 */ + /** + * 主站是否已失联超过最大有效期。 + * + *

由主站的任何存活信号刷新(见 {@link #markPrimaryContact()}),不限于快照。 + */ private boolean isStale(long now) { - return maxStaleMillis > 0 && now - lastSyncAt > maxStaleMillis; + return maxStaleMillis > 0 && now - lastPrimaryContactAt > maxStaleMillis; + } + + /** + * 记录一次「主站仍在」的证据。 + * + *

用于快照之外的常规存活信号(例如主站每 30 分钟的可用性检查)。 + * 只要主站在线,本节点的新鲜度就会持续被刷新,因此备机不会因为 + * 「订阅内容长期不变、主站没理由重发快照」而被判过期。 + */ + public void markPrimaryContact() { + lastPrimaryContactAt = System.currentTimeMillis(); } private Snapshot loadSnapshot(Path directory) throws IOException { diff --git a/src/main/java/lion/storageNode.java b/src/main/java/lion/storageNode.java index d812adf..e710629 100644 --- a/src/main/java/lion/storageNode.java +++ b/src/main/java/lion/storageNode.java @@ -166,6 +166,12 @@ public class storageNode { log.info(String.valueOf(msg)); AbstractMessage abstractMessage = (AbstractMessage) msg; + // 来自已认证主站通道的任何消息都是「主站仍在」的证据。 + // 备机的过期语义是「主站失联」,因此用这个信号刷新新鲜度,而不是只在收到快照时刷新: + // 订阅内容长期不变时主站没有理由重发整份快照,若只看快照就会误判过期。 + if (ctx.channel().equals(server)) + subscriptionSnapshotStore.markPrimaryContact(); + switch (abstractMessage.messageType){ case AbstractMessage.IDENTITY_MESSAGE -> { IdentityMessage identityMessage = (IdentityMessage) abstractMessage; diff --git a/src/test/java/lion/Service/SubscriptionSnapshotStoreTest.java b/src/test/java/lion/Service/SubscriptionSnapshotStoreTest.java index 1e515c0..381e495 100644 --- a/src/test/java/lion/Service/SubscriptionSnapshotStoreTest.java +++ b/src/test/java/lion/Service/SubscriptionSnapshotStoreTest.java @@ -98,6 +98,43 @@ class SubscriptionSnapshotStoreTest { assertNull(reloaded.lookup("v2", "public-key-1"), "过期快照不得继续分发"); } + /** + * 主站的常规存活信号(例如每 30 分钟的可用性检查)必须刷新新鲜度。 + * + *

这是「过期 ⇔ 主站失联」的关键:快照是内容寻址的,内容长期不变时主站没有理由 + * 重发整份快照;若新鲜度只由「收到快照」驱动,备机会在内容不动的第 7 天误判过期。 + */ + @Test + void primaryContactWithoutNewSnapshotKeepsSubscriptionFresh(@TempDir Path directory) throws Exception { + SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 1, 1024 * 1024); + SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis()); + assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code()); + + // 超过有效期(1 秒),期间只有主站存活信号、没有新快照 + Thread.sleep(1_200); + assertEquals("expired", store.status().state(), "有效期已过且无任何主站信号,应先判过期"); + + store.markPrimaryContact(); + assertEquals("ready", store.status().state(), "收到主站存活信号后应恢复可用"); + assertNotNull(store.lookup("v2", "public-key-1"), "主站在线期间必须能取到订阅"); + // 内容仍是最初那份,说明续期靠的是存活信号,而不是内容变化 + assertArrayEquals("v2-content".getBytes(StandardCharsets.UTF_8), store.lookup("v2", "public-key-1").content()); + } + + /** 主站失联(长时间没有任何信号)后仍必须判过期,不能因加入存活信号而永不失效。 */ + @Test + void staysExpiredWhenPrimaryContactStops(@TempDir Path directory) throws Exception { + SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 1, 1024 * 1024); + SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis()); + assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code()); + store.markPrimaryContact(); + assertEquals("ready", store.status().state()); + + Thread.sleep(1_200); // 之后主站再无任何消息 + assertEquals("expired", store.status().state(), "主站失联超过有效期必须判过期"); + assertNull(store.lookup("v2", "public-key-1"), "失联后不得继续分发"); + } + private SubscriptionSnapshotMessage message(String publicKey, String v2, String clash, long generatedAt) throws Exception { byte[] v2Bytes = v2.getBytes(StandardCharsets.UTF_8); byte[] clashBytes = clash.getBytes(StandardCharsets.UTF_8);