发布 v1.1.0:加密双向同步、从机日志TTL与一键部署

This commit is contained in:
2026-03-08 02:49:17 +08:00
parent 78b9685ee1
commit 394789f567
15 changed files with 1047 additions and 411 deletions

View File

@@ -1,6 +1,11 @@
# Redis Clipboard Sync # 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 脚本 linux/ # Linux 脚本
build-and-run.sh # 一键打包并运行 build-and-run.sh # 一键打包并运行
run.sh # 仅运行(需已打包) run.sh # 仅运行(需已打包)
build-slave-release.sh # 生成从机发布包 tar.gz
slave-package/ # 从机安装脚本与 systemd 模板
config.master.properties.example config.master.properties.example
config.slave.properties.example config.slave.properties.example
config.properties # 运行前从 example 复制并修改 config.properties # 运行前从 example 复制并修改
README.md README.md
剪切板同步.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 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-<version>.tar.gz`
### 2) 传输到从机并解压
```bash
tar -xzf redis-clip-sync-slave-<version>.tar.gz
cd redis-clip-sync-slave-<version>
```
### 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` 一致。 - **config.properties**:主配置,字段与 `剪切板同步.md` 一致。
@@ -75,7 +127,11 @@ redis.port=6379
redis.password= redis.password=
master.target.channels=machine_a,machine_b 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) ### 从机 (Slave)
@@ -83,33 +139,53 @@ master.save.dir=./clipboard_logs/
```properties ```properties
role=SLAVE role=SLAVE
redis.host=192.168.1.100 redis.host=192.168.1.100
redis.port=6379
redis.password=
slave.listen.channel=machine_a slave.listen.channel=machine_a
slave.id=192.168.1.50 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/ RedisClipSync/
- config.properties - config.properties
- clipboard_logs/ # 自动创建Master - clipboard_logs/ # 自动创建Master/Slave
- 192.168.1.50.txt # 从机 A 的记录 - master_to_slave.log # 主机发往从机的日志
- 192.168.1.51.txt # 从机 B 的记录 - slave_to_master.log # 从机发往主机的日志
``` ```
## 功能验证 ## 功能验证
1. **主机 → 从机**:主机复制一段文字(如 "Hello Slave"从机剪切板应自动更新为该内容,且从机不会把该内容再上传回主机(防回环) 1. **主机 → 从机**:主机复制一段文字(如 "Hello Slave"主机会广播到所有目标频道;各从机收到后仅写 `master_to_slave.log`,不修改本机剪切板
2. **从机 → 主机**:从机复制一段文字(如 "Password123"),主机会在 `./clipboard_logs/<slave.id>.txt` 追加一条带时间戳的记录,主机剪切板保持不变 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 Password123
------------------ ------------------

View File

@@ -6,10 +6,10 @@
<groupId>com.redisclipsync</groupId> <groupId>com.redisclipsync</groupId>
<artifactId>redis-clip-sync</artifactId> <artifactId>redis-clip-sync</artifactId>
<version>1.0.0</version> <version>1.1.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>Redis Clipboard Sync</name> <name>Redis Clipboard Sync</name>
<description>Redis-based bidirectional clipboard sync: Master broadcasts to slaves, slaves upload to master and save to files.</description> <description>Redis-based bidirectional clipboard log sync with AES-GCM transport encryption and slave-side log TTL.</description>
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -23,6 +23,12 @@
<artifactId>jedis</artifactId> <artifactId>jedis</artifactId>
<version>3.9.0</version> <version>3.9.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -65,6 +71,11 @@
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@@ -1,15 +1,23 @@
import redis.clients.jedis.Jedis; import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub; 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.*;
import java.awt.datatransfer.*; import java.awt.datatransfer.*;
import java.io.*; import java.io.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.security.SecureRandom;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.Locale;
import java.util.Properties; import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class RedisClipSync { public class RedisClipSync {
@@ -21,22 +29,42 @@ public class RedisClipSync {
// 主机专用配置 // 主机专用配置
private static String[] MASTER_TARGETS; 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_LISTEN_CHANNEL;
private static String SLAVE_ID; // 用于生成文件名,如 192.168.1.5.txt 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 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 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) { public static void main(String[] args) {
String configPath = args.length > 0 ? args[0] : (System.getProperty("config") != null ? System.getProperty("config") : "config.properties"); String configPath = args.length > 0 ? args[0] : (System.getProperty("config") != null ? System.getProperty("config") : "config.properties");
loadConfig(configPath); loadConfig(configPath);
validateConfig();
createDirIfMaster(); createDirIfMaster();
if (!checkRedisConnection()) { if (!checkRedisConnection()) {
System.err.println("请检查 config 中 redis.host / redis.port / redis.password 是否与 Redis 服务器一致,且本机能否访问该 Redis。"); System.err.println("请检查 config 中 redis.host / redis.port / redis.password 是否与 Redis 服务器一致,且本机能否访问该 Redis。");
@@ -56,6 +84,8 @@ public class RedisClipSync {
startSlaveDownloadListener(); startSlaveDownloadListener();
// 2. 监听本地 -> 上传给主机 (存文件) // 2. 监听本地 -> 上传给主机 (存文件)
startClipboardPoller("SLAVE", RedisClipSync::uploadToMaster); startClipboardPoller("SLAVE", RedisClipSync::uploadToMaster);
// 3. 定时裁剪从机主下发日志每条仅保留30秒
startSlaveLogPruner();
} }
} }
@@ -71,17 +101,6 @@ public class RedisClipSync {
while (true) { while (true) {
try { try {
Thread.sleep(500); 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); Transferable contents = clipboard.getContents(null);
if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor)) { if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
String currentText = (String) contents.getTransferData(DataFlavor.stringFlavor); String currentText = (String) contents.getTransferData(DataFlavor.stringFlavor);
@@ -91,7 +110,9 @@ public class RedisClipSync {
callback.onCopy(currentText); callback.onCopy(currentText);
} }
} }
} catch (Exception e) { /* 忽略 */ } } catch (Exception e) {
System.err.println("[" + sourceName + "] 剪切板轮询异常: " + e.getClass().getSimpleName() + " - " + e.getMessage());
}
} }
}).start(); }).start();
} }
@@ -105,10 +126,12 @@ public class RedisClipSync {
System.out.println("[Master] 本地复制,广播中..."); System.out.println("[Master] 本地复制,广播中...");
Jedis jedis = getJedis(); Jedis jedis = getJedis();
try { try {
String outboundMessage = encodeOutgoingMessage(content);
for (String channel : MASTER_TARGETS) { for (String channel : MASTER_TARGETS) {
String ch = channel.trim(); String ch = channel.trim();
if (!ch.isEmpty()) { 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) { } catch (Exception e) {
@@ -127,12 +150,19 @@ public class RedisClipSync {
jedis.subscribe(new JedisPubSub() { jedis.subscribe(new JedisPubSub() {
@Override @Override
public void onMessage(String channel, String message) { public void onMessage(String channel, String message) {
// 消息格式约定: "SLAVE_ID|||CONTENT" String decrypted = decodeIncomingMessage(message);
String[] parts = message.split("\\|\\|\\|", 2); if (decrypted == null) {
if (parts.length == 2) { System.err.println("[Master] 收到无法解密的上传消息,已忽略");
String slaveId = parts[0]; return;
String content = parts[1]; }
saveToFile(slaveId, content);
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); }, UPLOAD_CHANNEL);
@@ -145,20 +175,44 @@ public class RedisClipSync {
}).start(); }).start();
} }
// 动作:保存到文件 private static void appendLog(String logFileName, String direction, String source, String target, String content) {
private static void saveToFile(String fileName, 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 { try {
File dir = new File(MASTER_SAVE_DIR); File dir = new File(LOG_SAVE_DIR);
if (!dir.exists()) dir.mkdirs(); 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"); 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); 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) { } catch (IOException e) {
System.err.println("文件写入失败: " + e.getMessage()); System.err.println("文件写入失败: " + e.getMessage());
} }
@@ -172,8 +226,9 @@ public class RedisClipSync {
private static void uploadToMaster(String content) { private static void uploadToMaster(String content) {
System.out.println("[Slave] 检测到复制,正在上传给主机..."); System.out.println("[Slave] 检测到复制,正在上传给主机...");
try (Jedis jedis = getJedis()) { try (Jedis jedis = getJedis()) {
String payload = SLAVE_ID + "|||" + content; String payload = buildUploadPayload(SLAVE_ID, content);
jedis.publish(UPLOAD_CHANNEL, payload); String outboundPayload = encodeOutgoingMessage(payload);
jedis.publish(UPLOAD_CHANNEL, outboundPayload);
} catch (Exception e) { } catch (Exception e) {
System.err.println("上传失败: " + e.getMessage()); System.err.println("上传失败: " + e.getMessage());
} }
@@ -188,27 +243,24 @@ public class RedisClipSync {
jedis.subscribe(new JedisPubSub() { jedis.subscribe(new JedisPubSub() {
@Override @Override
public void onMessage(String channel, String message) { public void onMessage(String channel, String message) {
System.out.println("[Slave] 收到主机内容,写入剪切板"); String decrypted = decodeIncomingMessage(message);
writeToClipboard(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); }, SLAVE_LISTEN_CHANNEL);
} catch (Exception e) { } catch (Exception e) {
System.err.println("[Slave] Redis 断开,重连中... 原因: " + e.getMessage());
try { Thread.sleep(3000); } catch (InterruptedException ig) { Thread.currentThread().interrupt(); } try { Thread.sleep(3000); } catch (InterruptedException ig) { Thread.currentThread().interrupt(); }
} }
} }
}).start(); }).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; 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 是否可连 */ /** 启动前检查 Redis 是否可连 */
private static boolean checkRedisConnection() { private static boolean checkRedisConnection() {
try (Jedis jedis = getJedis()) { try (Jedis jedis = getJedis()) {
@@ -231,11 +305,9 @@ public class RedisClipSync {
} }
private static void createDirIfMaster() { private static void createDirIfMaster() {
if ("MASTER".equalsIgnoreCase(ROLE)) { File dir = new File(LOG_SAVE_DIR);
File dir = new File(MASTER_SAVE_DIR);
if (!dir.exists()) dir.mkdirs(); if (!dir.exists()) dir.mkdirs();
} }
}
private static void loadConfig(String configPath) { private static void loadConfig(String configPath) {
try (FileInputStream fis = new FileInputStream(configPath)) { try (FileInputStream fis = new FileInputStream(configPath)) {
@@ -248,14 +320,274 @@ public class RedisClipSync {
REDIS_PASS = props.getProperty("redis.password", ""); REDIS_PASS = props.getProperty("redis.password", "");
MASTER_TARGETS = props.getProperty("master.target.channels", "").split(","); 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_LISTEN_CHANNEL = props.getProperty("slave.listen.channel", "default");
SLAVE_ID = props.getProperty("slave.id", SLAVE_LISTEN_CHANNEL); 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) { } catch (Exception e) {
System.err.println("配置加载失败,请检查 " + configPath + ": " + e.getMessage()); System.err.println("配置加载失败,请检查 " + configPath + ": " + e.getMessage());
System.exit(1); 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;
}
}
} }

View File

@@ -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));
}
}

View File

@@ -1,4 +1,4 @@
# 主机示例配置:复制为 config.properties 后修改 # 主机示例配置v1.1.0:复制为 config.properties 后修改
# 角色MASTER(主机) 或 SLAVE(从机) # 角色MASTER(主机) 或 SLAVE(从机)
role=MASTER role=MASTER
@@ -12,5 +12,13 @@ redis.password=
# 目标频道列表:主机将剪贴板内容推送到这些频道,多个频道用逗号分隔 # 目标频道列表:主机将剪贴板内容推送到这些频道,多个频道用逗号分隔
master.target.channels=machine_a,machine_b 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

View File

@@ -1,4 +1,4 @@
# 从机示例配置:复制为 config.properties 后修改 # 从机示例配置v1.1.0:复制为 config.properties 后修改
# 角色MASTER(主机) 或 SLAVE(从机) # 角色MASTER(主机) 或 SLAVE(从机)
role=SLAVE role=SLAVE
@@ -14,3 +14,16 @@ redis.password=
slave.listen.channel=machine_a slave.listen.channel=machine_a
# 从机唯一标识ID用于区分不同的从机设备建议使用IP或主机名 # 从机唯一标识ID用于区分不同的从机设备建议使用IP或主机名
slave.id=192.168.1.50 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

65
linux/build-slave-release.sh Executable file
View File

@@ -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"

View File

@@ -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"

18
linux/slave-package/bin/logs.sh Executable file
View File

@@ -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"

View File

@@ -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"))."

View File

@@ -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

25
linux/slave-package/bin/stop.sh Executable file
View File

@@ -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"

View File

@@ -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

View File

@@ -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 ```properties
# ================= 基础配置 ================= # 基础
role=MASTER role=MASTER|SLAVE
redis.host=127.0.0.1 redis.host=127.0.0.1
redis.port=6379 redis.port=6379
redis.password= 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 master.target.channels=machine_a,machine_b
# 2. (新) 上传数据保存的根目录 (不填默认在 jar 包同级目录) master.id=master
master.save.dir=./clipboard_logs/
``` # 从机
---
### 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.listen.channel=machine_a
# 发给主机时的身份标识 (决定了主机端生成的文件名) slave.id=slave-01
slave.id=192.168.1.50
# 日志目录
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 - 主机:`master.target.channels=machine_a,machine_b`
role=MASTER - 从机1`slave.listen.channel=machine_a`
redis.host=127.0.0.1 - 从机2`slave.listen.channel=machine_b`
# 同步给哪些机器 (控制下发) - 三端 `crypto.key` 保持一致
master.target.channels=machine_a,machine_b
# 保存接收文件的路径
master.save.dir=./clipboard_logs/
## 发布与安装Linux
1. 主机执行 `./linux/build-slave-release.sh` 生成从机发布包。
2.`dist/redis-clip-sync-slave-<version>.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)`

117
部署方案.md Normal file
View File

@@ -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-<version>/`
- 压缩包:`dist/redis-clip-sync-slave-<version>.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-<version>.tar.gz
cd redis-clip-sync-slave-<version>
```
### 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`