发布 v1.1.0:加密双向同步、从机日志TTL与一键部署
This commit is contained in:
96
README.md
96
README.md
@@ -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
|
||||||
|
|
||||||
------------------
|
------------------
|
||||||
|
|||||||
15
code/pom.xml
15
code/pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
code/src/test/java/RedisClipSyncTest.java
Normal file
130
code/src/test/java/RedisClipSyncTest.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
65
linux/build-slave-release.sh
Executable 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"
|
||||||
59
linux/slave-package/bin/install-slave.sh
Executable file
59
linux/slave-package/bin/install-slave.sh
Executable 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
18
linux/slave-package/bin/logs.sh
Executable 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"
|
||||||
44
linux/slave-package/bin/start.sh
Executable file
44
linux/slave-package/bin/start.sh
Executable 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"))."
|
||||||
18
linux/slave-package/bin/status.sh
Executable file
18
linux/slave-package/bin/status.sh
Executable 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
25
linux/slave-package/bin/stop.sh
Executable 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"
|
||||||
18
linux/slave-package/systemd/redis-clip-sync.service.tpl
Normal file
18
linux/slave-package/systemd/redis-clip-sync.service.tpl
Normal 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
|
||||||
400
剪切板同步.md
400
剪切板同步.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
|
```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
117
部署方案.md
Normal 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`。
|
||||||
Reference in New Issue
Block a user