From 394789f56732a38f0a5a9d641bbb4de4cff64f72 Mon Sep 17 00:00:00 2001 From: mangmang <362165265@qq.com> Date: Sun, 8 Mar 2026 02:49:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=20v1.1.0=EF=BC=9A=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E5=8F=8C=E5=90=91=E5=90=8C=E6=AD=A5=E3=80=81=E4=BB=8E?= =?UTF-8?q?=E6=9C=BA=E6=97=A5=E5=BF=97TTL=E4=B8=8E=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 96 +++- code/pom.xml | 15 +- code/src/main/java/RedisClipSync.java | 424 ++++++++++++++++-- code/src/test/java/RedisClipSyncTest.java | 130 ++++++ config.master.properties.example | 14 +- config.slave.properties.example | 15 +- linux/build-slave-release.sh | 65 +++ linux/slave-package/bin/install-slave.sh | 59 +++ linux/slave-package/bin/logs.sh | 18 + linux/slave-package/bin/start.sh | 44 ++ linux/slave-package/bin/status.sh | 18 + linux/slave-package/bin/stop.sh | 25 ++ .../systemd/redis-clip-sync.service.tpl | 18 + 剪切板同步.md | 400 +++-------------- 部署方案.md | 117 +++++ 15 files changed, 1047 insertions(+), 411 deletions(-) create mode 100644 code/src/test/java/RedisClipSyncTest.java create mode 100755 linux/build-slave-release.sh create mode 100755 linux/slave-package/bin/install-slave.sh create mode 100755 linux/slave-package/bin/logs.sh create mode 100755 linux/slave-package/bin/start.sh create mode 100755 linux/slave-package/bin/status.sh create mode 100755 linux/slave-package/bin/stop.sh create mode 100644 linux/slave-package/systemd/redis-clip-sync.service.tpl create mode 100644 部署方案.md diff --git a/README.md b/README.md index 061e719..59873b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # Redis Clipboard Sync -基于 Redis 的双向剪切板同步:主机将本地剪切板广播到从机,从机将本地剪切板上传到主机并按从机 ID 追加写入 TXT 文件。 +基于 Redis 的双向剪切板日志同步:主机将本地剪切板广播到从机并记录日志,从机将本地剪切板上传到主机并记录日志。 + +## 版本对应 + +- `v1.1.0`(当前):双向传输 AES-256-GCM 加密;从机 `master_to_slave.log` 按条目 TTL 清理(默认 30 秒)。 +- `v1.0.0`:双向日志同步(无传输加密、无按条目 TTL 清理)。 ## 目录结构 @@ -16,11 +21,14 @@ RedisClipSync/ linux/ # Linux 脚本 build-and-run.sh # 一键打包并运行 run.sh # 仅运行(需已打包) + build-slave-release.sh # 生成从机发布包 tar.gz + slave-package/ # 从机安装脚本与 systemd 模板 config.master.properties.example config.slave.properties.example config.properties # 运行前从 example 复制并修改 README.md 剪切板同步.md + 部署方案.md ``` ## 构建与运行 @@ -60,6 +68,50 @@ java -jar code/target/redis-clip-sync.jar java -jar code/target/redis-clip-sync.jar /path/to/config.properties ``` +## 内网快速部署(主机打包,从机解压即装) + +### 1) 在主机生成从机发布包 + +```bash +chmod +x linux/build-slave-release.sh +./linux/build-slave-release.sh +``` + +生成物:`dist/redis-clip-sync-slave-.tar.gz` + +### 2) 传输到从机并解压 + +```bash +tar -xzf redis-clip-sync-slave-.tar.gz +cd redis-clip-sync-slave- +``` + +### 3) 从机只改配置并一键安装开机自启 + +编辑 `conf/config.properties`(至少修改 `redis.host`、`slave.listen.channel`、`slave.id`、`crypto.key`),然后执行: + +```bash +sudo ./bin/install-slave.sh +``` + +默认安装目录:`/opt/redis-clip-sync` + +### 4) 常用运维命令 + +```bash +systemctl status redis-clip-sync +systemctl restart redis-clip-sync +journalctl -u redis-clip-sync -f +``` + +### 5) 一键验收命令(从机) + +```bash +bash -c 'set -e; systemctl is-enabled redis-clip-sync >/dev/null; systemctl is-active redis-clip-sync >/dev/null; test -f /opt/redis-clip-sync/lib/redis-clip-sync.jar; test -f /opt/redis-clip-sync/conf/config.properties; echo "[PASS] service enabled + active, files ready"' +``` + +如需完整部署步骤与回滚方案,见 `部署方案.md`。 + ## 配置 - **config.properties**:主配置,字段与 `剪切板同步.md` 一致。 @@ -75,7 +127,11 @@ redis.port=6379 redis.password= master.target.channels=machine_a,machine_b -master.save.dir=./clipboard_logs/ +master.id=master +log.save.dir=./clipboard_logs/ + +crypto.enabled=true +crypto.key=REPLACE_WITH_BASE64_32_BYTE_KEY ``` ### 从机 (Slave) @@ -83,33 +139,53 @@ master.save.dir=./clipboard_logs/ ```properties role=SLAVE redis.host=192.168.1.100 +redis.port=6379 +redis.password= + slave.listen.channel=machine_a slave.id=192.168.1.50 +log.save.dir=./clipboard_logs/ + +crypto.enabled=true +crypto.key=REPLACE_WITH_BASE64_32_BYTE_KEY +slave.log.ttl.seconds=30 +slave.log.prune.interval.seconds=5 ``` -`slave.id` 决定主机端生成的文件名(如 `192.168.1.50.txt`)。 +`master.id` 和 `slave.id` 会写入日志的 `source/target` 字段,便于审计。 + +`crypto.enabled=true` 时,主从消息会使用 AES-256-GCM 加密传输(`crypto.key` 需主从一致)。 + +从机只对 `master_to_slave.log` 启用按条目保留:每条默认保留 30 秒,超时自动从日志内清除。 + +## 1主2从配置示例 + +- 主机:`master.target.channels=machine_a,machine_b` +- 从机1:`slave.listen.channel=machine_a` +- 从机2:`slave.listen.channel=machine_b` +- 三端 `crypto.enabled=true` 且 `crypto.key` 必须一致 ## 运行目录说明 -主机启动后会在配置的 `master.save.dir`(默认 `./clipboard_logs/`)下自动创建保存目录。建议在项目根目录运行脚本,这样 `config.properties` 与 `clipboard_logs/` 均在根目录下: +程序启动后会在配置的 `log.save.dir`(默认 `./clipboard_logs/`)下自动创建保存目录。建议在项目根目录运行脚本,这样 `config.properties` 与 `clipboard_logs/` 均在根目录下: ``` RedisClipSync/ - config.properties - - clipboard_logs/ # 自动创建(Master) - - 192.168.1.50.txt # 从机 A 的记录 - - 192.168.1.51.txt # 从机 B 的记录 + - clipboard_logs/ # 自动创建(Master/Slave) + - master_to_slave.log # 主机发往从机的日志 + - slave_to_master.log # 从机发往主机的日志 ``` ## 功能验证 -1. **主机 → 从机**:主机复制一段文字(如 "Hello Slave"),从机剪切板应自动更新为该内容,且从机不会把该内容再上传回主机(防回环)。 -2. **从机 → 主机**:从机复制一段文字(如 "Password123"),主机会在 `./clipboard_logs/.txt` 中追加一条带时间戳的记录,主机剪切板保持不变。 +1. **主机 → 从机**:主机复制一段文字(如 "Hello Slave"),主机会广播到所有目标频道;各从机收到后仅写 `master_to_slave.log`,不修改本机剪切板。 +2. **从机 → 主机**:从机复制一段文字(如 "Password123"),主机会在 `./clipboard_logs/slave_to_master.log` 追加日志记录。 记录格式示例: ``` -[2024-05-20 10:00:01] +[2024-05-20 10:00:01] direction=slave_to_master source=192.168.1.50 target=master Password123 ------------------ diff --git a/code/pom.xml b/code/pom.xml index 86a660b..393e2fd 100644 --- a/code/pom.xml +++ b/code/pom.xml @@ -6,10 +6,10 @@ com.redisclipsync redis-clip-sync - 1.0.0 + 1.1.0 jar Redis Clipboard Sync - Redis-based bidirectional clipboard sync: Master broadcasts to slaves, slaves upload to master and save to files. + Redis-based bidirectional clipboard log sync with AES-GCM transport encryption and slave-side log TTL. UTF-8 @@ -23,6 +23,12 @@ jedis 3.9.0 + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + @@ -65,6 +71,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + diff --git a/code/src/main/java/RedisClipSync.java b/code/src/main/java/RedisClipSync.java index 2dbf354..6e751b4 100644 --- a/code/src/main/java/RedisClipSync.java +++ b/code/src/main/java/RedisClipSync.java @@ -1,15 +1,23 @@ import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPubSub; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; import java.awt.*; import java.awt.datatransfer.*; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardOpenOption; +import java.security.SecureRandom; import java.text.SimpleDateFormat; +import java.util.Base64; import java.util.Date; +import java.util.Locale; import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class RedisClipSync { @@ -21,22 +29,42 @@ public class RedisClipSync { // 主机专用配置 private static String[] MASTER_TARGETS; - private static String MASTER_SAVE_DIR; + private static String LOG_SAVE_DIR; + private static String MASTER_ID; // 从机专用配置 private static String SLAVE_LISTEN_CHANNEL; private static String SLAVE_ID; // 用于生成文件名,如 192.168.1.5.txt + // 传输加密配置 + private static boolean CRYPTO_ENABLED; + private static SecretKeySpec CRYPTO_KEY; + private static final String ENCRYPTED_PREFIX = "ENCv1:"; + private static final int GCM_IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + // 从机日志保留策略(仅 master_to_slave.log) + private static int SLAVE_LOG_TTL_SECONDS = 30; + private static int SLAVE_LOG_PRUNE_INTERVAL_SECONDS = 5; + // 公共上传通道 private static final String UPLOAD_CHANNEL = "global_clip_upload"; + private static final String UNKNOWN_SLAVE_ID = "unknown"; + private static final Pattern UPLOAD_PAYLOAD_PATTERN = Pattern.compile("\\{\"slaveId\":\"((?:\\\\.|[^\"])*)\",\"content\":\"((?:\\\\.|[^\"])*)\",\"timestamp\":(\\d+)\\}"); + private static final String MASTER_TO_SLAVE_LOG_FILE = "master_to_slave.log"; + private static final String SLAVE_TO_MASTER_LOG_FILE = "slave_to_master.log"; + private static final String LOG_ENTRY_SEPARATOR = "\n\n------------------\n\n"; + private static final Pattern LOG_ENTRY_TIME_PATTERN = Pattern.compile("^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]", Pattern.MULTILINE); // 状态控制 private static String lastContent = ""; - private static volatile boolean isRemoteUpdate = false; // 锁标记,防止回环 + private static final Object SLAVE_MASTER_TO_SLAVE_LOG_LOCK = new Object(); public static void main(String[] args) { String configPath = args.length > 0 ? args[0] : (System.getProperty("config") != null ? System.getProperty("config") : "config.properties"); loadConfig(configPath); + validateConfig(); createDirIfMaster(); if (!checkRedisConnection()) { System.err.println("请检查 config 中 redis.host / redis.port / redis.password 是否与 Redis 服务器一致,且本机能否访问该 Redis。"); @@ -56,6 +84,8 @@ public class RedisClipSync { startSlaveDownloadListener(); // 2. 监听本地 -> 上传给主机 (存文件) startClipboardPoller("SLAVE", RedisClipSync::uploadToMaster); + // 3. 定时裁剪从机主下发日志(每条仅保留30秒) + startSlaveLogPruner(); } } @@ -71,17 +101,6 @@ public class RedisClipSync { while (true) { try { Thread.sleep(500); - // 如果刚被远程写入,则跳过本次检测,防止死循环 - if (isRemoteUpdate) { - isRemoteUpdate = false; - // 更新本地缓存,认为当前内容是已知的 - Transferable c = clipboard.getContents(null); - if (c != null && c.isDataFlavorSupported(DataFlavor.stringFlavor)) { - lastContent = (String) c.getTransferData(DataFlavor.stringFlavor); - } - continue; - } - Transferable contents = clipboard.getContents(null); if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor)) { String currentText = (String) contents.getTransferData(DataFlavor.stringFlavor); @@ -91,7 +110,9 @@ public class RedisClipSync { callback.onCopy(currentText); } } - } catch (Exception e) { /* 忽略 */ } + } catch (Exception e) { + System.err.println("[" + sourceName + "] 剪切板轮询异常: " + e.getClass().getSimpleName() + " - " + e.getMessage()); + } } }).start(); } @@ -105,10 +126,12 @@ public class RedisClipSync { System.out.println("[Master] 本地复制,广播中..."); Jedis jedis = getJedis(); try { + String outboundMessage = encodeOutgoingMessage(content); for (String channel : MASTER_TARGETS) { String ch = channel.trim(); if (!ch.isEmpty()) { - jedis.publish(ch, content); + jedis.publish(ch, outboundMessage); + appendLog(MASTER_TO_SLAVE_LOG_FILE, "master_to_slave", MASTER_ID, ch, content); } } } catch (Exception e) { @@ -127,12 +150,19 @@ public class RedisClipSync { jedis.subscribe(new JedisPubSub() { @Override public void onMessage(String channel, String message) { - // 消息格式约定: "SLAVE_ID|||CONTENT" - String[] parts = message.split("\\|\\|\\|", 2); - if (parts.length == 2) { - String slaveId = parts[0]; - String content = parts[1]; - saveToFile(slaveId, content); + String decrypted = decodeIncomingMessage(message); + if (decrypted == null) { + System.err.println("[Master] 收到无法解密的上传消息,已忽略"); + return; + } + + UploadMessage uploadMessage = parseUploadPayload(decrypted); + if (uploadMessage != null) { + String slaveId = uploadMessage.slaveId; + String content = uploadMessage.content; + appendLog(SLAVE_TO_MASTER_LOG_FILE, "slave_to_master", slaveId, MASTER_ID, content); + } else { + System.err.println("[Master] 收到无法解析的上传消息,已忽略"); } } }, UPLOAD_CHANNEL); @@ -145,20 +175,44 @@ public class RedisClipSync { }).start(); } - // 动作:保存到文件 - private static void saveToFile(String fileName, String content) { + private static void appendLog(String logFileName, String direction, String source, String target, String content) { + String safeLogFileName = sanitizeFileName(logFileName); + boolean slaveMasterToSlaveLog = isSlaveMasterToSlaveLog(safeLogFileName); + + if (slaveMasterToSlaveLog) { + synchronized (SLAVE_MASTER_TO_SLAVE_LOG_LOCK) { + appendLogInternal(safeLogFileName, direction, source, target, content); + pruneSlaveMasterToSlaveLogIfNeeded(System.currentTimeMillis()); + } + return; + } + + appendLogInternal(safeLogFileName, direction, source, target, content); + } + + private static void appendLogInternal(String safeLogFileName, String direction, String source, String target, String content) { try { - File dir = new File(MASTER_SAVE_DIR); + File dir = new File(LOG_SAVE_DIR); if (!dir.exists()) dir.mkdirs(); - File file = new File(dir, fileName + ".txt"); + File file = new File(dir, safeLogFileName); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - String logEntry = String.format("[%s]\n%s\n\n------------------\n\n", sdf.format(new Date()), content); + String safeSource = sanitizeFileName(source); + String safeTarget = sanitizeFileName(target); + String logEntry = String.format( + "[%s] direction=%s source=%s target=%s\n%s%s", + sdf.format(new Date()), + direction, + safeSource, + safeTarget, + content == null ? "" : content, + LOG_ENTRY_SEPARATOR + ); Files.write(file.toPath(), logEntry.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); - System.out.println("[Master] 已保存来自 [" + fileName + "] 的数据"); + System.out.println("[Log] 已写入 " + file.getPath()); } catch (IOException e) { System.err.println("文件写入失败: " + e.getMessage()); } @@ -172,8 +226,9 @@ public class RedisClipSync { private static void uploadToMaster(String content) { System.out.println("[Slave] 检测到复制,正在上传给主机..."); try (Jedis jedis = getJedis()) { - String payload = SLAVE_ID + "|||" + content; - jedis.publish(UPLOAD_CHANNEL, payload); + String payload = buildUploadPayload(SLAVE_ID, content); + String outboundPayload = encodeOutgoingMessage(payload); + jedis.publish(UPLOAD_CHANNEL, outboundPayload); } catch (Exception e) { System.err.println("上传失败: " + e.getMessage()); } @@ -188,27 +243,24 @@ public class RedisClipSync { jedis.subscribe(new JedisPubSub() { @Override public void onMessage(String channel, String message) { - System.out.println("[Slave] 收到主机内容,写入剪切板"); - writeToClipboard(message); + String decrypted = decodeIncomingMessage(message); + if (decrypted == null) { + System.err.println("[Slave] 收到无法解密的主机消息,已忽略"); + return; + } + + System.out.println("[Slave] 收到主机内容,写入日志"); + appendLog(MASTER_TO_SLAVE_LOG_FILE, "master_to_slave", channel, SLAVE_ID, decrypted); } }, SLAVE_LISTEN_CHANNEL); } catch (Exception e) { + System.err.println("[Slave] Redis 断开,重连中... 原因: " + e.getMessage()); try { Thread.sleep(3000); } catch (InterruptedException ig) { Thread.currentThread().interrupt(); } } } }).start(); } - private static void writeToClipboard(String text) { - isRemoteUpdate = true; // 标记:这是远程来的,不要回传! - try { - Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); - clipboard.setContents(new StringSelection(text), null); - } catch (Exception e) { - isRemoteUpdate = false; - } - } - // ========================================== // 辅助工具 // ========================================== @@ -218,6 +270,28 @@ public class RedisClipSync { return jedis; } + private static void startSlaveLogPruner() { + if (!"SLAVE".equalsIgnoreCase(ROLE) || SLAVE_LOG_TTL_SECONDS <= 0) { + return; + } + + new Thread(() -> { + while (true) { + try { + synchronized (SLAVE_MASTER_TO_SLAVE_LOG_LOCK) { + pruneSlaveMasterToSlaveLogIfNeeded(System.currentTimeMillis()); + } + Thread.sleep(Math.max(1, SLAVE_LOG_PRUNE_INTERVAL_SECONDS) * 1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (Exception e) { + System.err.println("[Slave] 日志裁剪失败: " + e.getMessage()); + } + } + }, "slave-log-pruner").start(); + } + /** 启动前检查 Redis 是否可连 */ private static boolean checkRedisConnection() { try (Jedis jedis = getJedis()) { @@ -231,10 +305,8 @@ public class RedisClipSync { } private static void createDirIfMaster() { - if ("MASTER".equalsIgnoreCase(ROLE)) { - File dir = new File(MASTER_SAVE_DIR); - if (!dir.exists()) dir.mkdirs(); - } + File dir = new File(LOG_SAVE_DIR); + if (!dir.exists()) dir.mkdirs(); } private static void loadConfig(String configPath) { @@ -248,14 +320,274 @@ public class RedisClipSync { REDIS_PASS = props.getProperty("redis.password", ""); MASTER_TARGETS = props.getProperty("master.target.channels", "").split(","); - MASTER_SAVE_DIR = props.getProperty("master.save.dir", "./clipboard_logs/"); + LOG_SAVE_DIR = normalizeLogDir(props.getProperty("log.save.dir", props.getProperty("master.save.dir", "./clipboard_logs/"))); + MASTER_ID = props.getProperty("master.id", "master"); SLAVE_LISTEN_CHANNEL = props.getProperty("slave.listen.channel", "default"); SLAVE_ID = props.getProperty("slave.id", SLAVE_LISTEN_CHANNEL); + CRYPTO_ENABLED = Boolean.parseBoolean(props.getProperty("crypto.enabled", "false")); + applyCryptoConfig(CRYPTO_ENABLED, props.getProperty("crypto.key", "")); + + SLAVE_LOG_TTL_SECONDS = Integer.parseInt(props.getProperty("slave.log.ttl.seconds", "30")); + SLAVE_LOG_PRUNE_INTERVAL_SECONDS = Integer.parseInt(props.getProperty("slave.log.prune.interval.seconds", "5")); + } catch (Exception e) { System.err.println("配置加载失败,请检查 " + configPath + ": " + e.getMessage()); System.exit(1); } } + + private static void validateConfig() { + try { + validateConfigValues(ROLE, MASTER_TARGETS); + } catch (IllegalArgumentException e) { + System.err.println("配置校验失败: " + e.getMessage()); + System.exit(1); + } + } + + static void validateConfigValues(String role, String[] masterTargets) { + String normalizedRole = role == null ? "" : role.trim().toUpperCase(Locale.ROOT); + if (!"MASTER".equals(normalizedRole) && !"SLAVE".equals(normalizedRole)) { + throw new IllegalArgumentException("role 仅支持 MASTER 或 SLAVE,当前值: " + role); + } + + if ("MASTER".equals(normalizedRole) && !hasAtLeastOneNonBlank(masterTargets)) { + throw new IllegalArgumentException("MASTER 模式下 master.target.channels 不能为空"); + } + + if (SLAVE_LOG_TTL_SECONDS < 0) { + throw new IllegalArgumentException("slave.log.ttl.seconds 不能为负数"); + } + + if (SLAVE_LOG_PRUNE_INTERVAL_SECONDS <= 0) { + throw new IllegalArgumentException("slave.log.prune.interval.seconds 必须大于 0"); + } + } + + static boolean hasAtLeastOneNonBlank(String[] values) { + if (values == null) { + return false; + } + + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return true; + } + } + return false; + } + + static String sanitizeFileName(String rawFileName) { + if (rawFileName == null) { + return UNKNOWN_SLAVE_ID; + } + + String sanitized = rawFileName.trim().replaceAll("[^a-zA-Z0-9._-]", "_"); + if (sanitized.isEmpty() || ".".equals(sanitized) || "..".equals(sanitized)) { + return UNKNOWN_SLAVE_ID; + } + + if (sanitized.length() > 128) { + return sanitized.substring(0, 128); + } + return sanitized; + } + + static String normalizeLogDir(String rawDir) { + if (rawDir == null || rawDir.trim().isEmpty()) { + return "./clipboard_logs/"; + } + return rawDir.trim(); + } + + static String buildUploadPayload(String slaveId, String content) { + String safeSlaveId = escapeJson(slaveId == null ? UNKNOWN_SLAVE_ID : slaveId); + String safeContent = escapeJson(content == null ? "" : content); + return "{\"slaveId\":\"" + safeSlaveId + "\",\"content\":\"" + safeContent + "\",\"timestamp\":" + System.currentTimeMillis() + "}"; + } + + static void applyCryptoConfig(boolean enabled, String keyBase64) { + CRYPTO_ENABLED = enabled; + CRYPTO_KEY = null; + + if (!enabled) { + return; + } + + if (keyBase64 == null || keyBase64.trim().isEmpty()) { + throw new IllegalArgumentException("crypto.enabled=true 时,crypto.key 不能为空"); + } + + byte[] keyBytes; + try { + keyBytes = Base64.getDecoder().decode(keyBase64.trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("crypto.key 必须是 Base64 编码"); + } + + if (keyBytes.length != 32) { + throw new IllegalArgumentException("crypto.key 解码后长度必须为 32 字节 (AES-256)"); + } + + CRYPTO_KEY = new SecretKeySpec(keyBytes, "AES"); + } + + static String encodeOutgoingMessage(String plainText) { + if (!CRYPTO_ENABLED) { + return plainText; + } + + try { + byte[] iv = new byte[GCM_IV_LENGTH_BYTES]; + SECURE_RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, CRYPTO_KEY, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] encrypted = cipher.doFinal((plainText == null ? "" : plainText).getBytes(StandardCharsets.UTF_8)); + return ENCRYPTED_PREFIX + Base64.getEncoder().encodeToString(iv) + ":" + Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new IllegalStateException("消息加密失败: " + e.getMessage(), e); + } + } + + static String decodeIncomingMessage(String encryptedMessage) { + if (!CRYPTO_ENABLED) { + return encryptedMessage; + } + + if (encryptedMessage == null || !encryptedMessage.startsWith(ENCRYPTED_PREFIX)) { + return null; + } + + String payload = encryptedMessage.substring(ENCRYPTED_PREFIX.length()); + String[] parts = payload.split(":", 2); + if (parts.length != 2) { + return null; + } + + try { + byte[] iv = Base64.getDecoder().decode(parts[0]); + byte[] encrypted = Base64.getDecoder().decode(parts[1]); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, CRYPTO_KEY, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] plainBytes = cipher.doFinal(encrypted); + return new String(plainBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + return null; + } + } + + static boolean isSlaveMasterToSlaveLog(String safeLogFileName) { + return "SLAVE".equalsIgnoreCase(ROLE) && MASTER_TO_SLAVE_LOG_FILE.equals(safeLogFileName); + } + + static void pruneSlaveMasterToSlaveLogIfNeeded(long nowMillis) { + if (!"SLAVE".equalsIgnoreCase(ROLE) || SLAVE_LOG_TTL_SECONDS <= 0) { + return; + } + + File file = new File(new File(LOG_SAVE_DIR), MASTER_TO_SLAVE_LOG_FILE); + if (!file.exists()) { + return; + } + + try { + String raw = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + if (raw.trim().isEmpty()) { + return; + } + + String[] entries = raw.split(Pattern.quote(LOG_ENTRY_SEPARATOR)); + long ttlMillis = SLAVE_LOG_TTL_SECONDS * 1000L; + StringBuilder keptEntries = new StringBuilder(); + + for (String entry : entries) { + String trimmed = entry.trim(); + if (trimmed.isEmpty()) { + continue; + } + + Long entryTime = parseLogEntryTimeMillis(trimmed); + if (entryTime == null) { + continue; + } + + if (nowMillis - entryTime <= ttlMillis) { + keptEntries.append(trimmed).append(LOG_ENTRY_SEPARATOR); + } + } + + Files.write(file.toPath(), keptEntries.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } catch (IOException e) { + System.err.println("[Slave] 裁剪日志失败: " + e.getMessage()); + } + } + + static void configureSlaveLogRetentionForTest(String role, String logSaveDir, int ttlSeconds, int pruneIntervalSeconds) { + ROLE = role; + LOG_SAVE_DIR = logSaveDir; + SLAVE_LOG_TTL_SECONDS = ttlSeconds; + SLAVE_LOG_PRUNE_INTERVAL_SECONDS = pruneIntervalSeconds; + } + + static Long parseLogEntryTimeMillis(String entry) { + Matcher matcher = LOG_ENTRY_TIME_PATTERN.matcher(entry); + if (!matcher.find()) { + return null; + } + + String timeString = matcher.group(1); + try { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + return sdf.parse(timeString).getTime(); + } catch (Exception e) { + return null; + } + } + + static UploadMessage parseUploadPayload(String message) { + if (message == null || message.trim().isEmpty()) { + return null; + } + + Matcher matcher = UPLOAD_PAYLOAD_PATTERN.matcher(message); + if (!matcher.matches()) { + return null; + } + + String slaveId = unescapeJson(matcher.group(1)); + String content = unescapeJson(matcher.group(2)); + return new UploadMessage(slaveId, content); + } + + static String escapeJson(String value) { + String escaped = value; + escaped = escaped.replace("\\", "\\\\"); + escaped = escaped.replace("\"", "\\\""); + escaped = escaped.replace("\n", "\\n"); + escaped = escaped.replace("\r", "\\r"); + escaped = escaped.replace("\t", "\\t"); + return escaped; + } + + static String unescapeJson(String value) { + String unescaped = value; + unescaped = unescaped.replace("\\n", "\n"); + unescaped = unescaped.replace("\\r", "\r"); + unescaped = unescaped.replace("\\t", "\t"); + unescaped = unescaped.replace("\\\"", "\""); + unescaped = unescaped.replace("\\\\", "\\"); + return unescaped; + } + + static class UploadMessage { + final String slaveId; + final String content; + + UploadMessage(String slaveId, String content) { + this.slaveId = slaveId; + this.content = content; + } + } } diff --git a/code/src/test/java/RedisClipSyncTest.java b/code/src/test/java/RedisClipSyncTest.java new file mode 100644 index 0000000..3b4b812 --- /dev/null +++ b/code/src/test/java/RedisClipSyncTest.java @@ -0,0 +1,130 @@ +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.Base64; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RedisClipSyncTest { + + @AfterEach + void resetGlobalState() { + RedisClipSync.applyCryptoConfig(false, ""); + RedisClipSync.configureSlaveLogRetentionForTest("SLAVE", "./clipboard_logs/", 30, 5); + } + + @Test + void sanitizeFileNameReplacesUnsafeCharacters() { + String sanitized = RedisClipSync.sanitizeFileName("machine/a\\b:c*?"); + assertEquals("machine_a_b_c__", sanitized); + } + + @Test + void sanitizeFileNameFallsBackToUnknownWhenEmpty() { + assertEquals("unknown", RedisClipSync.sanitizeFileName(" ")); + assertEquals("unknown", RedisClipSync.sanitizeFileName("..")); + } + + @Test + void uploadPayloadRoundTripSupportsDelimiterCharacters() { + String content = "line1|||line2\nline3"; + String payload = RedisClipSync.buildUploadPayload("slave-1", content); + + RedisClipSync.UploadMessage message = RedisClipSync.parseUploadPayload(payload); + assertNotNull(message); + assertEquals("slave-1", message.slaveId); + assertEquals(content, message.content); + } + + @Test + void parseUploadPayloadReturnsNullForInvalidJson() { + assertNull(RedisClipSync.parseUploadPayload("not-json")); + assertNull(RedisClipSync.parseUploadPayload("{}")); + } + + @Test + void validateConfigRejectsInvalidRole() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> RedisClipSync.validateConfigValues("WRONG", new String[]{"machine_a"}) + ); + assertTrue(ex.getMessage().contains("role")); + } + + @Test + void validateConfigRejectsMasterWithoutChannels() { + assertThrows( + IllegalArgumentException.class, + () -> RedisClipSync.validateConfigValues("MASTER", new String[]{" "}) + ); + } + + @Test + void validateConfigAcceptsMasterWithChannel() { + RedisClipSync.validateConfigValues("MASTER", new String[]{"machine_a"}); + } + + @Test + void cryptoRoundTripWorksWhenEnabled() { + String keyBase64 = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + RedisClipSync.applyCryptoConfig(true, keyBase64); + + String encrypted = RedisClipSync.encodeOutgoingMessage("hello-secure"); + assertTrue(encrypted.startsWith("ENCv1:")); + assertEquals("hello-secure", RedisClipSync.decodeIncomingMessage(encrypted)); + } + + @Test + void decodeIncomingMessageRejectsInvalidEncryptedPayload() { + String keyBase64 = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + RedisClipSync.applyCryptoConfig(true, keyBase64); + + assertNull(RedisClipSync.decodeIncomingMessage("plain-text")); + assertNull(RedisClipSync.decodeIncomingMessage("ENCv1:bad-format")); + } + + @Test + void applyCryptoConfigRejectsNon32ByteKey() { + String keyBase64 = Base64.getEncoder().encodeToString("short-key".getBytes(StandardCharsets.UTF_8)); + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> RedisClipSync.applyCryptoConfig(true, keyBase64) + ); + assertTrue(ex.getMessage().contains("32")); + } + + @Test + void pruneSlaveLogKeepsOnlyEntriesWithinTtl() throws Exception { + Path tempDir = Files.createTempDirectory("redis-clip-sync-test"); + Path logFile = tempDir.resolve("master_to_slave.log"); + + long nowMillis = System.currentTimeMillis(); + String oldTime = formatTs(nowMillis - 31_000L); + String newTime = formatTs(nowMillis - 5_000L); + + String entrySeparator = "\n\n------------------\n\n"; + String oldEntry = "[" + oldTime + "] direction=master_to_slave source=master target=slave\nold" + entrySeparator; + String newEntry = "[" + newTime + "] direction=master_to_slave source=master target=slave\nnew" + entrySeparator; + Files.write(logFile, (oldEntry + newEntry).getBytes(StandardCharsets.UTF_8)); + + RedisClipSync.configureSlaveLogRetentionForTest("SLAVE", tempDir.toString(), 30, 5); + RedisClipSync.pruneSlaveMasterToSlaveLogIfNeeded(nowMillis); + + String remaining = new String(Files.readAllBytes(logFile), StandardCharsets.UTF_8); + assertTrue(remaining.contains("new")); + assertTrue(!remaining.contains("old")); + } + + private static String formatTs(long millis) { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(millis)); + } +} diff --git a/config.master.properties.example b/config.master.properties.example index 2a956b4..9afcdc5 100644 --- a/config.master.properties.example +++ b/config.master.properties.example @@ -1,4 +1,4 @@ -# 主机示例配置:复制为 config.properties 后修改 +# 主机示例配置(v1.1.0):复制为 config.properties 后修改 # 角色:MASTER(主机) 或 SLAVE(从机) role=MASTER @@ -12,5 +12,13 @@ redis.password= # 目标频道列表:主机将剪贴板内容推送到这些频道,多个频道用逗号分隔 master.target.channels=machine_a,machine_b -# 剪贴板日志保存目录:用于持久化剪贴板历史记录 -master.save.dir=./clipboard_logs/ + +# 主机标识:用于日志中的 source/target 字段 +master.id=master +# 统一日志目录:主机和从机都记录到该目录(本机) +log.save.dir=./clipboard_logs/ + +# 传输加密开关(建议开启) +crypto.enabled=true +# AES-256 密钥(Base64,解码后必须为32字节),主从必须一致 +crypto.key=REPLACE_WITH_BASE64_32_BYTE_KEY diff --git a/config.slave.properties.example b/config.slave.properties.example index 81654d0..281ff43 100644 --- a/config.slave.properties.example +++ b/config.slave.properties.example @@ -1,4 +1,4 @@ -# 从机示例配置:复制为 config.properties 后修改 +# 从机示例配置(v1.1.0):复制为 config.properties 后修改 # 角色:MASTER(主机) 或 SLAVE(从机) role=SLAVE @@ -14,3 +14,16 @@ redis.password= slave.listen.channel=machine_a # 从机唯一标识ID:用于区分不同的从机设备(建议使用IP或主机名) slave.id=192.168.1.50 + +# 统一日志目录:主机下发记录与本机上报行为都写到该目录(本机) +log.save.dir=./clipboard_logs/ + +# 传输加密开关(建议开启) +crypto.enabled=true +# AES-256 密钥(Base64,解码后必须为32字节),主从必须一致 +crypto.key=REPLACE_WITH_BASE64_32_BYTE_KEY + +# 从机 master_to_slave.log 每条日志保留时长(秒) +slave.log.ttl.seconds=30 +# 从机日志裁剪周期(秒) +slave.log.prune.interval.seconds=5 diff --git a/linux/build-slave-release.sh b/linux/build-slave-release.sh new file mode 100755 index 0000000..b96ca24 --- /dev/null +++ b/linux/build-slave-release.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +DIST_DIR="$PROJECT_ROOT/dist" + +cd "$PROJECT_ROOT" + +echo "========================================" +echo " RedisClipSync - Build Slave Release" +echo "========================================" +echo + +echo "[1/4] Packaging application jar..." +mvn -f code/pom.xml -q clean package -DskipTests + +VERSION="$(mvn -f code/pom.xml -q -DforceStdout help:evaluate -Dexpression=project.version)" +VERSION="${VERSION##*$'\n'}" + +PACKAGE_NAME="redis-clip-sync-slave-${VERSION}" +STAGE_DIR="$DIST_DIR/$PACKAGE_NAME" +ARCHIVE_PATH="$DIST_DIR/${PACKAGE_NAME}.tar.gz" + +echo "[2/4] Preparing release directory..." +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR" + +mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/lib" "$STAGE_DIR/conf" "$STAGE_DIR/systemd" "$STAGE_DIR/logs" "$STAGE_DIR/run" + +cp "$PROJECT_ROOT/code/target/redis-clip-sync.jar" "$STAGE_DIR/lib/redis-clip-sync.jar" +cp "$PROJECT_ROOT/config.slave.properties.example" "$STAGE_DIR/conf/config.properties" +cp "$PROJECT_ROOT/linux/slave-package/bin/start.sh" "$STAGE_DIR/bin/start.sh" +cp "$PROJECT_ROOT/linux/slave-package/bin/stop.sh" "$STAGE_DIR/bin/stop.sh" +cp "$PROJECT_ROOT/linux/slave-package/bin/status.sh" "$STAGE_DIR/bin/status.sh" +cp "$PROJECT_ROOT/linux/slave-package/bin/logs.sh" "$STAGE_DIR/bin/logs.sh" +cp "$PROJECT_ROOT/linux/slave-package/bin/install-slave.sh" "$STAGE_DIR/bin/install-slave.sh" +cp "$PROJECT_ROOT/linux/slave-package/systemd/redis-clip-sync.service.tpl" "$STAGE_DIR/systemd/redis-clip-sync.service.tpl" + +chmod +x "$STAGE_DIR/bin/start.sh" "$STAGE_DIR/bin/stop.sh" "$STAGE_DIR/bin/status.sh" "$STAGE_DIR/bin/logs.sh" "$STAGE_DIR/bin/install-slave.sh" + +cat > "$STAGE_DIR/README_DEPLOY.md" <<'EOF' +# Slave Deploy Quick Start + +1. Edit `conf/config.properties`. +2. Install and enable startup service: + +```bash +sudo ./bin/install-slave.sh +``` + +3. Check service status: + +```bash +systemctl status redis-clip-sync +``` +EOF + +echo "[3/4] Creating tar.gz archive..." +mkdir -p "$DIST_DIR" +tar -czf "$ARCHIVE_PATH" -C "$DIST_DIR" "$PACKAGE_NAME" + +echo "[4/4] Done" +echo "Release directory: $STAGE_DIR" +echo "Release archive: $ARCHIVE_PATH" diff --git a/linux/slave-package/bin/install-slave.sh b/linux/slave-package/bin/install-slave.sh new file mode 100755 index 0000000..555a33e --- /dev/null +++ b/linux/slave-package/bin/install-slave.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo "Please run as root: sudo ./bin/install-slave.sh" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PACKAGE_ROOT="$(dirname "$SCRIPT_DIR")" + +INSTALL_DIR="${1:-/opt/redis-clip-sync}" +SERVICE_NAME="${SERVICE_NAME:-redis-clip-sync}" +RUN_USER="${RUN_USER:-${SUDO_USER:-root}}" +RUN_GROUP="${RUN_GROUP:-$RUN_USER}" +TEMPLATE_PATH="$PACKAGE_ROOT/systemd/redis-clip-sync.service.tpl" +TARGET_SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" + +if [[ ! -f "$TEMPLATE_PATH" ]]; then + echo "Template not found: $TEMPLATE_PATH" + exit 1 +fi + +echo "Installing to: $INSTALL_DIR" +mkdir -p "$INSTALL_DIR" + +if [[ -f "$INSTALL_DIR/conf/config.properties" ]]; then + cp "$INSTALL_DIR/conf/config.properties" "$INSTALL_DIR/conf/config.properties.bak.$(date +%Y%m%d%H%M%S)" + echo "Existing config backed up in $INSTALL_DIR/conf" +fi + +cp -a "$PACKAGE_ROOT/bin" "$INSTALL_DIR/" +cp -a "$PACKAGE_ROOT/lib" "$INSTALL_DIR/" +cp -a "$PACKAGE_ROOT/conf" "$INSTALL_DIR/" +cp -a "$PACKAGE_ROOT/systemd" "$INSTALL_DIR/" +mkdir -p "$INSTALL_DIR/logs" "$INSTALL_DIR/run" + +chown -R "$RUN_USER:$RUN_GROUP" "$INSTALL_DIR" +chmod +x "$INSTALL_DIR/bin/start.sh" "$INSTALL_DIR/bin/stop.sh" "$INSTALL_DIR/bin/status.sh" "$INSTALL_DIR/bin/logs.sh" + +SERVICE_CONTENT="$(<"$TEMPLATE_PATH")" +SERVICE_CONTENT="${SERVICE_CONTENT//__INSTALL_DIR__/$INSTALL_DIR}" +SERVICE_CONTENT="${SERVICE_CONTENT//__RUN_USER__/$RUN_USER}" +SERVICE_CONTENT="${SERVICE_CONTENT//__RUN_GROUP__/$RUN_GROUP}" + +printf '%s\n' "$SERVICE_CONTENT" > "$TARGET_SERVICE_FILE" + +systemctl daemon-reload +systemctl enable --now "$SERVICE_NAME" + +echo +echo "Install completed" +echo "Service name: $SERVICE_NAME" +echo "Install dir: $INSTALL_DIR" +echo +echo "Useful commands:" +echo " systemctl status $SERVICE_NAME" +echo " journalctl -u $SERVICE_NAME -f" +echo " systemctl restart $SERVICE_NAME" diff --git a/linux/slave-package/bin/logs.sh b/linux/slave-package/bin/logs.sh new file mode 100755 index 0000000..890383e --- /dev/null +++ b/linux/slave-package/bin/logs.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_NAME="${SERVICE_NAME:-redis-clip-sync}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_HOME="$(dirname "$SCRIPT_DIR")" +LOG_FILE="$APP_HOME/logs/app.log" + +if command -v systemctl >/dev/null 2>&1 && systemctl cat "$SERVICE_NAME" >/dev/null 2>&1; then + exec journalctl -u "$SERVICE_NAME" -f +fi + +if [[ ! -f "$LOG_FILE" ]]; then + echo "Log file not found: $LOG_FILE" + exit 1 +fi + +exec tail -f "$LOG_FILE" diff --git a/linux/slave-package/bin/start.sh b/linux/slave-package/bin/start.sh new file mode 100755 index 0000000..6a18c8c --- /dev/null +++ b/linux/slave-package/bin/start.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_HOME="$(dirname "$SCRIPT_DIR")" +JAR_PATH="$APP_HOME/lib/redis-clip-sync.jar" +LOG_FILE="$APP_HOME/logs/app.log" +PID_FILE="$APP_HOME/run/app.pid" + +FOREGROUND="false" +CONFIG_PATH="$APP_HOME/conf/config.properties" + +for arg in "$@"; do + if [[ "$arg" == "--foreground" ]]; then + FOREGROUND="true" + elif [[ "$arg" == *.properties ]]; then + CONFIG_PATH="$arg" + fi +done + +if [[ ! -f "$JAR_PATH" ]]; then + echo "Jar not found: $JAR_PATH" + exit 1 +fi + +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "Config not found: $CONFIG_PATH" + exit 1 +fi + +mkdir -p "$APP_HOME/logs" "$APP_HOME/run" + +if [[ "$FOREGROUND" == "true" ]]; then + exec java -jar "$JAR_PATH" "$CONFIG_PATH" +fi + +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "RedisClipSync already running (pid=$(cat "$PID_FILE"))." + exit 0 +fi + +nohup java -jar "$JAR_PATH" "$CONFIG_PATH" >> "$LOG_FILE" 2>&1 & +echo $! > "$PID_FILE" +echo "RedisClipSync started (pid=$(cat "$PID_FILE"))." diff --git a/linux/slave-package/bin/status.sh b/linux/slave-package/bin/status.sh new file mode 100755 index 0000000..cce989d --- /dev/null +++ b/linux/slave-package/bin/status.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_NAME="${SERVICE_NAME:-redis-clip-sync}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_HOME="$(dirname "$SCRIPT_DIR")" +PID_FILE="$APP_HOME/run/app.pid" + +if command -v systemctl >/dev/null 2>&1 && systemctl cat "$SERVICE_NAME" >/dev/null 2>&1; then + systemctl status "$SERVICE_NAME" --no-pager + exit 0 +fi + +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "RedisClipSync running (pid=$(cat "$PID_FILE"))." +else + echo "RedisClipSync not running." +fi diff --git a/linux/slave-package/bin/stop.sh b/linux/slave-package/bin/stop.sh new file mode 100755 index 0000000..66df727 --- /dev/null +++ b/linux/slave-package/bin/stop.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_HOME="$(dirname "$SCRIPT_DIR")" +PID_FILE="$APP_HOME/run/app.pid" + +if [[ ! -f "$PID_FILE" ]]; then + echo "No pid file found, service may not be running." + exit 0 +fi + +PID="$(cat "$PID_FILE")" +if kill -0 "$PID" 2>/dev/null; then + kill "$PID" + sleep 1 + if kill -0 "$PID" 2>/dev/null; then + kill -9 "$PID" + fi + echo "RedisClipSync stopped (pid=$PID)." +else + echo "Process already stopped (pid=$PID)." +fi + +rm -f "$PID_FILE" diff --git a/linux/slave-package/systemd/redis-clip-sync.service.tpl b/linux/slave-package/systemd/redis-clip-sync.service.tpl new file mode 100644 index 0000000..0583b78 --- /dev/null +++ b/linux/slave-package/systemd/redis-clip-sync.service.tpl @@ -0,0 +1,18 @@ +[Unit] +Description=Redis Clip Sync Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=__RUN_USER__ +Group=__RUN_GROUP__ +WorkingDirectory=__INSTALL_DIR__ +ExecStart=__INSTALL_DIR__/bin/start.sh __INSTALL_DIR__/conf/config.properties --foreground +Restart=always +RestartSec=3 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/剪切板同步.md b/剪切板同步.md index a42dedf..1edf5c9 100644 --- a/剪切板同步.md +++ b/剪切板同步.md @@ -1,372 +1,74 @@ -这是一个非常棒的功能扩展。这样你的主机不仅是“广播站”,还是所有从机的“剪切板收集中心”。 +# Redis 剪切板同步说明 -为了实现这个需求,我们需要对架构做如下调整: +本文档与当前代码版本一一对应。 -1. **双向通信**:从机 (Slave) 也需要监听本地剪切板,并将内容发回 Redis。 -2. **上传通道**:新增一个公共的 Redis 频道(如 `clip_upload_channel`),所有从机都往这里发。 -3. **主机接收**:主机 (Master) 启动一个额外的 Redis 监听线程,收到数据后写入文件。 +## 版本对应 -以下是修改后的完整方案和代码。 +- `v1.1.0`(当前) + - 主机 -> 从机、从机 -> 主机均使用 AES-256-GCM 传输加密 + - 从机 `master_to_slave.log` 启用按条目 TTL 清理(默认 30 秒) + - 主从均写统一结构化日志:`direction/source/target` +- `v1.0.0` + - 仅双向日志同步,不包含传输加密和按条目 TTL 清理 ---- +## 当前运行逻辑 -### 1. 修改后的架构逻辑 +1. 主机监听本地剪切板变化,发布到 `master.target.channels` 的多个频道。 +2. 从机订阅自己的 `slave.listen.channel`,收到消息后写 `master_to_slave.log`。 +3. 从机监听本地剪切板变化,上传到 `global_clip_upload`。 +4. 主机订阅 `global_clip_upload`,收到后写 `slave_to_master.log`。 -* **Master (主机)**: -* 线程 1:监听本地剪切板 -> 广播给指定 Slave (原功能)。 -* **线程 2 (新)**:订阅 `clip_upload_channel` -> 解析消息 -> **写入 TXT 文件**。 +## 安全与保留策略 +- `crypto.enabled=true` 时启用加密。 +- `crypto.key` 必须是 Base64 编码,解码后 32 字节(AES-256)。 +- 从机仅对 `master_to_slave.log` 做 TTL 清理,不影响 `slave_to_master.log`。 -* **Slave (从机)**: -* 线程 1:订阅自己的频道 -> 写入本地剪切板 (原功能)。 -* **线程 2 (新)**:监听本地剪切板 -> 加上本机标识 -> 发布到 `clip_upload_channel`。 - - - ---- - -### 2. 配置文件更新 (`config.properties`) - -我们需要在配置文件中明确从机的“名称”(用于生成文件名,你可以填 IP)。 +## 核心配置项 ```properties -# ================= 基础配置 ================= -role=MASTER +# 基础 +role=MASTER|SLAVE redis.host=127.0.0.1 redis.port=6379 redis.password= -# ================= 同步配置 ================= -# 【从机配置】 -# 1. 监听哪个频道接收主机的命令 -slave.listen.channel=machine_a -# 2. (新) 本机标识 (可以是IP,也可以是别名)。 -# 主机收到数据后,会保存为 "这个名字.txt" (例如 192.168.1.50.txt) -slave.id=192.168.1.50 - -# 【主机配置】 -# 1. 同步目标列表 +# 主机 master.target.channels=machine_a,machine_b -# 2. (新) 上传数据保存的根目录 (不填默认在 jar 包同级目录) -master.save.dir=./clipboard_logs/ +master.id=master -``` - ---- - -### 3. 完整代码 (`RedisClipSync.java`) - -这个版本整合了双向逻辑。为了防止“死循环”(主机发给从机 -> 从机写入剪切板 -> 从机监听到变化又发回给主机),我们加了更严格的逻辑判断。 - -```java -import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPubSub; - -import java.awt.*; -import java.awt.datatransfer.*; -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Properties; - -public class RedisClipSync { - - // 配置项 - private static String ROLE; - private static String REDIS_HOST; - private static int REDIS_PORT; - private static String REDIS_PASS; - - // 主机专用配置 - private static String[] MASTER_TARGETS; - private static String MASTER_SAVE_DIR; - - // 从机专用配置 - private static String SLAVE_LISTEN_CHANNEL; - private static String SLAVE_ID; // 用于生成文件名,如 192.168.1.5.txt - - // 公共上传通道 - private static final String UPLOAD_CHANNEL = "global_clip_upload"; - - // 状态控制 - private static String lastContent = ""; - private static volatile boolean isRemoteUpdate = false; // 锁标记,防止回环 - - public static void main(String[] args) { - loadConfig(); - createDirIfMaster(); - - System.out.println(">>> JClipSync Pro 启动 <<<"); - System.out.println("角色: " + ROLE); - - if ("MASTER".equalsIgnoreCase(ROLE)) { - // 1. 监听本地 -> 广播给从机 - startClipboardPoller("MASTER", (content) -> { - publishToSlaves(content); - }); - // 2. 监听上传通道 -> 写入文件 - startMasterUploadListener(); - } else { - // 1. 监听 Redis -> 写入本地 - startSlaveDownloadListener(); - // 2. 监听本地 -> 上传给主机 (存文件) - startClipboardPoller("SLAVE", (content) -> { - uploadToMaster(content); - }); - } - } - - // ========================================== - // 公共模块: 剪切板轮询 - // ========================================== - interface ClipCallback { void onCopy(String content); } - - private static void startClipboardPoller(String sourceName, ClipCallback callback) { - Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); - new Thread(() -> { - System.out.println("[" + sourceName + "] 剪切板监听已启动..."); - while (true) { - try { - Thread.sleep(500); - // 如果刚被远程写入,则跳过本次检测,防止死循环 - if (isRemoteUpdate) { - isRemoteUpdate = false; - // 更新本地缓存,认为当前内容是已知的 - Transferable c = clipboard.getContents(null); - if(c != null && c.isDataFlavorSupported(DataFlavor.stringFlavor)) { - lastContent = (String) c.getTransferData(DataFlavor.stringFlavor); - } - continue; - } - - Transferable contents = clipboard.getContents(null); - if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor)) { - String currentText = (String) contents.getTransferData(DataFlavor.stringFlavor); - - if (!currentText.equals(lastContent)) { - lastContent = currentText; - // 触发回调 - callback.onCopy(currentText); - } - } - } catch (Exception e) { /* 忽略 */ } - } - }).start(); - } - - // ========================================== - // Master 逻辑 - // ========================================== - - // 动作:广播给所有从机 - private static void publishToSlaves(String content) { - System.out.println("[Master] 本地复制,广播中..."); - Jedis jedis = getJedis(); - try { - for (String channel : MASTER_TARGETS) { - jedis.publish(channel.trim(), content); - } - } catch(Exception e) { e.printStackTrace(); } - finally { if(jedis!=null) jedis.close(); } - } - - // 动作:监听上传通道并存文件 - private static void startMasterUploadListener() { - new Thread(() -> { - while(true) { - try (Jedis jedis = getJedis()) { - System.out.println("[Master] 等待从机上传数据..."); - jedis.subscribe(new JedisPubSub() { - @Override - public void onMessage(String channel, String message) { - // 消息格式约定: "SLAVE_ID|||CONTENT" - String[] parts = message.split("\\|\\|\\|", 2); - if (parts.length == 2) { - String slaveId = parts[0]; - String content = parts[1]; - saveToFile(slaveId, content); - } - } - }, UPLOAD_CHANNEL); - } catch (Exception e) { - System.err.println("[Master] Redis 断开,重连中..."); - try { Thread.sleep(3000); } catch (InterruptedException ig) {} - } - } - }).start(); - } - - // 动作:保存到文件 - private static void saveToFile(String fileName, String content) { - try { - // 确保目录存在 - File dir = new File(MASTER_SAVE_DIR); - if (!dir.exists()) dir.mkdirs(); - - // 文件路径:MASTER_SAVE_DIR/192.168.1.50.txt - File file = new File(dir, fileName + ".txt"); - - // 格式:[时间] 内容 - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - String logEntry = String.format("[%s]\n%s\n\n------------------\n\n", sdf.format(new Date()), content); - - // 追加写入 (Append) - Files.write(file.toPath(), logEntry.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); - - System.out.println("[Master] 已保存来自 [" + fileName + "] 的数据"); - } catch (IOException e) { - System.err.println("文件写入失败: " + e.getMessage()); - } - } - - // ========================================== - // Slave 逻辑 - // ========================================== - - // 动作:上传到 Master - private static void uploadToMaster(String content) { - System.out.println("[Slave] 检测到复制,正在上传给主机..."); - try (Jedis jedis = getJedis()) { - // 使用 ||| 作为分隔符,防止内容里有普通的 | - String payload = SLAVE_ID + "|||" + content; - jedis.publish(UPLOAD_CHANNEL, payload); - } catch (Exception e) { - System.err.println("上传失败: " + e.getMessage()); - } - } - - // 动作:接收主机指令 - private static void startSlaveDownloadListener() { - new Thread(() -> { - while(true) { - try (Jedis jedis = getJedis()) { - System.out.println("[Slave] 监听主机同步..."); - jedis.subscribe(new JedisPubSub() { - @Override - public void onMessage(String channel, String message) { - System.out.println("[Slave] 收到主机内容,写入剪切板"); - writeToClipboard(message); - } - }, SLAVE_LISTEN_CHANNEL); - } catch (Exception e) { - try { Thread.sleep(3000); } catch (InterruptedException ig) {} - } - } - }).start(); - } - - private static void writeToClipboard(String text) { - isRemoteUpdate = true; // 标记:这是远程来的,不要回传! - try { - Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); - clipboard.setContents(new StringSelection(text), null); - } catch (Exception e) { isRemoteUpdate = false; } - } - - // ========================================== - // 辅助工具 - // ========================================== - private static Jedis getJedis() { - Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT); - if (REDIS_PASS != null && !REDIS_PASS.isEmpty()) jedis.auth(REDIS_PASS); - return jedis; - } - - private static void createDirIfMaster() { - if ("MASTER".equalsIgnoreCase(ROLE)) { - File dir = new File(MASTER_SAVE_DIR); - if(!dir.exists()) dir.mkdirs(); - } - } - - private static void loadConfig() { - try (FileInputStream fis = new FileInputStream("config.properties")) { - Properties props = new Properties(); - props.load(fis); - - ROLE = props.getProperty("role", "SLAVE"); - REDIS_HOST = props.getProperty("redis.host", "127.0.0.1"); - REDIS_PORT = Integer.parseInt(props.getProperty("redis.port", "6379")); - REDIS_PASS = props.getProperty("redis.password", ""); - - MASTER_TARGETS = props.getProperty("master.target.channels", "").split(","); - MASTER_SAVE_DIR = props.getProperty("master.save.dir", "./clipboard_logs/"); - - SLAVE_LISTEN_CHANNEL = props.getProperty("slave.listen.channel", "default"); - // 如果没填 ID,默认用 listen channel 名字 - SLAVE_ID = props.getProperty("slave.id", SLAVE_LISTEN_CHANNEL); - - } catch (Exception e) { - System.err.println("配置加载失败,请检查 config.properties"); - System.exit(1); - } - } -} - -``` - ---- - -### 4. 部署与使用说明 - -#### A. 目录结构 - -程序启动后,主机端会自动生成文件夹: - -```text -/ - - sync.jar - - config.properties - - clipboard_logs/ <-- 自动生成 - - 192.168.1.50.txt <-- 从机 A 的记录 - - 192.168.1.51.txt <-- 从机 B 的记录 - -``` - -#### B. 从机配置 (`config.properties`) - -如果你希望主机生成的文件名是 IP 地址,请务必在 `slave.id` 填入 IP。 - -```properties -role=SLAVE -redis.host=192.168.1.100 -# 接收主机同步的频道 +# 从机 slave.listen.channel=machine_a -# 发给主机时的身份标识 (决定了主机端生成的文件名) -slave.id=192.168.1.50 +slave.id=slave-01 +# 日志目录 +log.save.dir=./clipboard_logs/ + +# 加密 +crypto.enabled=true +crypto.key=REPLACE_WITH_BASE64_32_BYTE_KEY + +# 从机日志TTL(仅 master_to_slave.log) +slave.log.ttl.seconds=30 +slave.log.prune.interval.seconds=5 ``` -#### C. 主机配置 +## 1主2从样例 -```properties -role=MASTER -redis.host=127.0.0.1 -# 同步给哪些机器 (控制下发) -master.target.channels=machine_a,machine_b -# 保存接收文件的路径 -master.save.dir=./clipboard_logs/ +- 主机:`master.target.channels=machine_a,machine_b` +- 从机1:`slave.listen.channel=machine_a` +- 从机2:`slave.listen.channel=machine_b` +- 三端 `crypto.key` 保持一致 +## 发布与安装(Linux) + +1. 主机执行 `./linux/build-slave-release.sh` 生成从机发布包。 +2. 将 `dist/redis-clip-sync-slave-.tar.gz` 传到从机。 +3. 从机解压后修改 `conf/config.properties`。 +4. 从机执行 `sudo ./bin/install-slave.sh`,自动启动并设置开机自启。 + +一键验收(从机): + +```bash +bash -c 'set -e; systemctl is-enabled redis-clip-sync >/dev/null; systemctl is-active redis-clip-sync >/dev/null; test -f /opt/redis-clip-sync/lib/redis-clip-sync.jar; echo "[PASS] redis-clip-sync deployed"' ``` - -### 5. 功能验证 - -1. **主机 -> 从机**:主机复制 "Hello Slave",从机剪切板自动更新。 -2. **从机 -> 主机**: -* 从机 (192.168.1.50) 复制 "Password123"。 -* 从机剪切板保持 "Password123"。 -* 主机会在 `./clipboard_logs/192.168.1.50.txt` 中追加写入: -```text -[2024-05-20 10:00:01] -Password123 - ------------------- - -``` - - -* **注意**:主机的剪切板**不会**变成 "Password123"(避免干扰主机操作,只做静默记录)。如果你希望主机剪切板也同步变,可以在 `saveToFile` 方法里调用 `writeToClipboard(content)`。 diff --git a/部署方案.md b/部署方案.md new file mode 100644 index 0000000..0fa52d9 --- /dev/null +++ b/部署方案.md @@ -0,0 +1,117 @@ +# RedisClipSync 内网部署方案(v1.1.0) + +本文档对应 `v1.1.0`,适用于「主机统一打包 -> 分发到多台从机 -> 从机一键安装并开机自启」场景。 + +## 1. 目标与范围 + +- 目标:降低内网批量部署成本,避免每台从机重复构建。 +- 范围:Linux 从机部署(systemd 管理),主机负责生成发布包。 +- 默认安装目录:`/opt/redis-clip-sync`。 + +## 2. 架构与角色 + +- 主机(MASTER):生成发布包并分发。 +- 从机(SLAVE):接收发布包,改配置后执行安装脚本。 +- Redis:内网统一服务,主从均连接此 Redis。 + +## 3. 前置条件 + +- 主机:已安装 Java 8+、Maven 3.x。 +- 从机:已安装 Java 8+、systemd、sudo。 +- 网络:从机可访问 Redis `host:port`。 +- 安全:`crypto.enabled=true`,主从 `crypto.key` 使用同一把 32-byte Base64 密钥。 + +## 4. 主机发布流程 + +在项目根目录执行: + +```bash +chmod +x linux/build-slave-release.sh +./linux/build-slave-release.sh +``` + +执行完成后会生成: + +- 目录:`dist/redis-clip-sync-slave-/` +- 压缩包:`dist/redis-clip-sync-slave-.tar.gz` + +发布包包含: + +- `bin/install-slave.sh`:安装 + 注册 systemd + enable --now +- `bin/start.sh|stop.sh|status.sh|logs.sh`:本地运维脚本 +- `lib/redis-clip-sync.jar` +- `conf/config.properties`(从机模板) +- `systemd/redis-clip-sync.service.tpl` + +## 5. 从机安装流程 + +### 5.1 解压发布包 + +```bash +tar -xzf redis-clip-sync-slave-.tar.gz +cd redis-clip-sync-slave- +``` + +### 5.2 修改从机配置 + +编辑 `conf/config.properties`,至少修改: + +- `redis.host` +- `slave.listen.channel` +- `slave.id` +- `crypto.key` +- (按需)`redis.password` + +### 5.3 执行安装 + +```bash +sudo ./bin/install-slave.sh +``` + +安装脚本会执行: + +1. 复制程序到 `/opt/redis-clip-sync` +2. 渲染并写入 `/etc/systemd/system/redis-clip-sync.service` +3. `systemctl daemon-reload` +4. `systemctl enable --now redis-clip-sync` + +## 6. 运维命令 + +```bash +systemctl status redis-clip-sync +systemctl restart redis-clip-sync +journalctl -u redis-clip-sync -f +``` + +## 7. 一键验收命令 + +在从机执行: + +```bash +bash -c 'set -e; systemctl is-enabled redis-clip-sync >/dev/null; systemctl is-active redis-clip-sync >/dev/null; test -f /opt/redis-clip-sync/lib/redis-clip-sync.jar; test -f /opt/redis-clip-sync/conf/config.properties; echo "[PASS] redis-clip-sync deployed and running"' +``` + +输出 `[PASS] ...` 表示安装、开机自启、运行状态和关键文件均通过。 + +## 8. 回滚与卸载 + +如需快速回滚: + +```bash +sudo systemctl disable --now redis-clip-sync +sudo rm -f /etc/systemd/system/redis-clip-sync.service +sudo systemctl daemon-reload +``` + +如需同时删除安装目录: + +```bash +sudo rm -rf /opt/redis-clip-sync +``` + +## 9. 常见问题 + +- 服务启动失败:先看 `journalctl -u redis-clip-sync -n 200 --no-pager`。 +- Redis 不通:检查 `redis.host/redis.port`、防火墙、密码。 +- 无法解密:确认主从 `crypto.enabled` 与 `crypto.key` 完全一致。 +- TTL 不生效:确认从机角色为 `SLAVE` 且 `slave.log.ttl.seconds>0`。