过期判定(真实缺陷): - 节点原先按内容里的 generatedAt 判过期,而 revision 是内容寻址的。内容长期不变时 主站每分钟重发同一 revision 会被判为 APPLY_OLD 直接丢弃,新鲜度永远停在首次接收 那天——于是主站明明在线且持续同步,备机仍在第 7 天开始返回 503。 - 改为按「最近一次成功接收并校验通过的主站快照时刻」判定:同 revision 重发会刷新 新鲜度;重启时以落盘内容的生成时间作为起点,避免重启即续期。 - 该时刻取自节点本地时钟、不参与签名,重放旧 revision 无法续期。 下载 HTTP 服务: - 请求行改按 ISO-8859-1 显式解码(原依赖平台默认字符集)。 - 查询串与键值拆分的 split 加 limit=2:参数值含 '='(如 base64 padding)不再被截断。 - sendFileRange 缓冲 1 KiB -> 64 KiB,降低大包下载的系统调用开销。 队列上报与通知: - server/node 通道加 volatile;主站未连接或通道失效时跳过状态上报,不再以 NPE 形式 被外层 catch 吞掉后每 5 秒刷一条 error(队列保留,等重连重放)。 - 无待上报任务时不再发送空数组。 - 已下载完成的通知移出 addToQueue 的持锁路径并改为异步,避免同步 HTTP 卡住节点主锁; 且仅生产构造启用,测试不外呼。 已下载完成的重复校验: - 归档校验结果按「路径 + 大小」缓存(大小变化或超一小时才重算),避免重连时把所有 未完成任务整包读取重算 CRC。 测试:13 项全过(新增 MultiThreadedHTTPServerTest 5 项;SubscriptionSnapshotStoreTest 增加「同 revision 重发续期」「超期未收到推送才过期」;DownloadCheckServiceTest 增加 有效归档跨重复入队判定一致)。
145 lines
8.2 KiB
Java
145 lines
8.2 KiB
Java
package lion.Service;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lion.CustomUtil;
|
|
import lion.Message.Main.SubscriptionAccountSnapshot;
|
|
import lion.Message.Main.SubscriptionBindingSnapshot;
|
|
import lion.Message.Main.SubscriptionSnapshotMessage;
|
|
import lion.Message.Main.SubscriptionSnapshotPayload;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.io.TempDir;
|
|
|
|
import javax.crypto.Mac;
|
|
import javax.crypto.spec.SecretKeySpec;
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Path;
|
|
import java.security.MessageDigest;
|
|
import java.util.Base64;
|
|
import java.util.HexFormat;
|
|
import java.util.zip.GZIPOutputStream;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
class SubscriptionSnapshotStoreTest {
|
|
private static final String SECRET = "snapshot-test-secret";
|
|
private final ObjectMapper mapper = CustomUtil.objectMapper;
|
|
|
|
@Test
|
|
void appliesSnapshotAndServesByHashedPublicKey(@TempDir Path directory) throws Exception {
|
|
SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
|
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis());
|
|
|
|
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code());
|
|
SubscriptionSnapshotStore.Lookup v2 = store.lookup("v2", "public-key-1");
|
|
SubscriptionSnapshotStore.Lookup clash = store.lookup("cat", "public-key-1");
|
|
assertArrayEquals("v2-content".getBytes(StandardCharsets.UTF_8), v2.content());
|
|
assertArrayEquals("clash-content".getBytes(StandardCharsets.UTF_8), clash.content());
|
|
assertEquals(SubscriptionSnapshotStore.APPLY_OLD, store.apply(message).code());
|
|
}
|
|
|
|
@Test
|
|
void rejectsTamperedPayloadAndKeepsPreviousSnapshot(@TempDir Path directory) throws Exception {
|
|
SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
|
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis());
|
|
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code());
|
|
message.setPayloadBase64(Base64.getEncoder().encodeToString("tampered".getBytes(StandardCharsets.UTF_8)));
|
|
assertEquals(SubscriptionSnapshotStore.APPLY_INVALID, store.apply(message).code());
|
|
assertArrayEquals("v2-content".getBytes(StandardCharsets.UTF_8), store.lookup("v2", "public-key-1").content());
|
|
}
|
|
|
|
@Test
|
|
void loadsLastGoodSnapshotAfterRestart(@TempDir Path directory) throws Exception {
|
|
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content", System.currentTimeMillis());
|
|
SubscriptionSnapshotStore first = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
|
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, first.apply(message).code());
|
|
SubscriptionSnapshotStore second = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
|
second.load();
|
|
assertArrayEquals("clash-content".getBytes(StandardCharsets.UTF_8), second.lookup("cat", "public-key-1").content());
|
|
}
|
|
|
|
/**
|
|
* 回归:节点曾按「内容里的 generatedAt」判过期,而 revision 是内容寻址的——
|
|
* 内容长期不变时主站每分钟重发同一 revision 会被判为 APPLY_OLD 直接丢弃,
|
|
* 新鲜度永远停在首次接收那天,于是主站在线且持续同步,备机仍在第 7 天开始 503。
|
|
* 现在过期只取决于「最近一次成功接收主站快照的时刻」。
|
|
*/
|
|
@Test
|
|
void repeatedSameRevisionRefreshesFreshnessAndNeverExpires(@TempDir Path directory) throws Exception {
|
|
// 有效期 1 秒,便于在用例内观察「不续期会过期、续期后恢复」。
|
|
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());
|
|
assertEquals("ready", store.status().state());
|
|
|
|
// 超过有效期且期间没有任何推送 → 过期(证明判定确实生效,不是恒 ready)
|
|
Thread.sleep(1_200);
|
|
assertEquals("expired", store.status().state(), "超过有效期且无推送应判过期");
|
|
|
|
// 同 revision 重发:仍是 APPLY_OLD(内容幂等),但必须刷新新鲜度
|
|
assertEquals(SubscriptionSnapshotStore.APPLY_OLD, store.apply(message).code());
|
|
assertEquals("ready", store.status().state(), "重新收到同 revision 后应恢复可用");
|
|
assertNotNull(store.lookup("v2", "public-key-1"), "持续同步期间必须能取到订阅");
|
|
}
|
|
|
|
/** 超过有效期后仍未收到任何快照,才判过期并停止分发。 */
|
|
@Test
|
|
void expiresOnlyAfterNoSnapshotArrivesWithinMaxAge(@TempDir Path directory) throws Exception {
|
|
SubscriptionSnapshotStore store = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
|
// 内容生成于 2 小时前,且重启后再没收到主站推送 → 超期。
|
|
SubscriptionSnapshotMessage message = message("public-key-1", "v2-content", "clash-content",
|
|
System.currentTimeMillis() - 7_200_000L);
|
|
assertEquals(SubscriptionSnapshotStore.APPLY_SUCCESS, store.apply(message).code());
|
|
|
|
SubscriptionSnapshotStore reloaded = new SubscriptionSnapshotStore(directory, SECRET, 3600, 1024 * 1024);
|
|
reloaded.load();
|
|
assertEquals("expired", reloaded.status().state(), "重启后未再收到快照,超期应判过期");
|
|
assertNull(reloaded.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);
|
|
SubscriptionAccountSnapshot account = new SubscriptionAccountSnapshot();
|
|
account.setAccountId(1);
|
|
account.setEnabled(true);
|
|
account.setV2ContentBase64(Base64.getEncoder().encodeToString(v2Bytes));
|
|
account.setV2Sha256(sha256(v2Bytes));
|
|
account.setClashContentBase64(Base64.getEncoder().encodeToString(clashBytes));
|
|
account.setClashSha256(sha256(clashBytes));
|
|
SubscriptionBindingSnapshot binding = new SubscriptionBindingSnapshot();
|
|
binding.setPublicKeySha256(sha256(publicKey.getBytes(StandardCharsets.UTF_8)));
|
|
binding.setAccountId(1);
|
|
SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload();
|
|
payload.setSchemaVersion(1);
|
|
payload.setAccounts(java.util.List.of(account));
|
|
payload.setBindings(java.util.List.of(binding));
|
|
byte[] json = mapper.writeValueAsBytes(payload);
|
|
byte[] compressed = gzip(json);
|
|
SubscriptionSnapshotMessage message = new SubscriptionSnapshotMessage();
|
|
message.setSchemaVersion(1);
|
|
message.setRevision(sha256(json));
|
|
message.setGeneratedAt(generatedAt);
|
|
message.setPayloadBase64(Base64.getEncoder().encodeToString(compressed));
|
|
message.setPayloadSha256(sha256(compressed));
|
|
String input = "1\n" + message.getRevision() + "\n" + generatedAt + "\n" + message.getPayloadSha256();
|
|
Mac mac = Mac.getInstance("HmacSHA256");
|
|
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
message.setSignature(hex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))));
|
|
return message;
|
|
}
|
|
|
|
private static byte[] gzip(byte[] bytes) throws Exception {
|
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { gzip.write(bytes); }
|
|
return output.toByteArray();
|
|
}
|
|
|
|
private static String sha256(byte[] bytes) throws Exception {
|
|
return hex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
|
}
|
|
|
|
private static String hex(byte[] bytes) { return HexFormat.of().formatHex(bytes); }
|
|
}
|