add 新增 同步目标端服务端加密(SSE-S3/SSE-KMS)可选配置

目标连接此前不发送任何服务端加密请求头,实际加密方式完全由存储桶默认策略
决定,换到默认策略不同或不支持 KMS 的服务端时行为不可控。

新增连接扩展配置 sseMode(NONE/SSE_S3/SSE_KMS)与 sseKmsKeyId,显式指定加密
方式。默认 NONE 保持不发送加密请求头的原有行为,对不支持 SSE 的服务端无影响。

- S3SseSetting 统一负责加密配置的解析、校验与请求头应用
- SyncS3OssClient 增加带 SSE 的上传,走底层 doCustomUpload 自行构建
  PutObject 请求,不修改公共 oss 模块 API
- 上传后比对 headObject 返回的实际加密方式,不一致仅告警不中断同步,
  便于发现服务端静默忽略加密请求头的情况
- 连接保存与连通性测试阶段前置校验非法的加密配置组合
This commit is contained in:
没钱 2026-09-03 18:18:59 +08:00
parent 6a3b4ed4c3
commit d857b4b74b
5 changed files with 228 additions and 2 deletions

View File

@ -1,6 +1,7 @@
package org.dromara.sync.connector.s3;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
@ -23,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* 基于仓库原生 S3 客户端的目标连接器基础实现
*/
@Slf4j
public abstract class AbstractS3TargetConnector implements TargetConnector {
private final Map<Long, SyncS3OssClient> clients = new ConcurrentHashMap<>();
@ -30,6 +32,7 @@ public abstract class AbstractS3TargetConnector implements TargetConnector {
@Override
public ConnectorTestResult test(SyncConnection connection) {
try {
S3SseSetting.from(parseJson(connection.getConfigJson()));
client(connection).headBucket(connection.getBucketName());
return ConnectorTestResult.success("目标存储桶访问成功");
} catch (Exception e) {
@ -44,12 +47,36 @@ public abstract class AbstractS3TargetConnector implements TargetConnector {
.setContentType(request.contentType())
.setMetadata(request.metadata());
SyncS3OssClient client = client(connection);
PutObjectResult result = client.upload(request.objectKey(), request.path(), options);
S3SseSetting sse = S3SseSetting.from(parseJson(connection.getConfigJson()));
PutObjectResult result = client.upload(connection.getBucketName(), request.objectKey(),
request.path(), options, sse);
HeadObjectResponse head = client.headObject(connection.getBucketName(), result.key());
verifyEncryption(sse, head, result.key());
return new TargetWriteResult(result.key(), head.versionId(), head.eTag(),
head.contentLength(), result.url(), head.metadata());
}
/**
* 校验服务端实际生效的加密方式与连接配置是否一致
*
* <p>不同厂商对加密请求头的支持程度不一致部分服务端会静默忽略此处仅告警不中断同步
* 便于在日志中及时发现目标端不支持所选加密方式的情况</p>
*
* @param sse 期望的加密设置
* @param head 对象元信息
* @param objectKey 对象键
*/
private void verifyEncryption(S3SseSetting sse, HeadObjectResponse head, String objectKey) {
if (!sse.enabled()) {
return;
}
String actual = head.serverSideEncryptionAsString();
if (!sse.expectedAlgorithm().equals(actual)) {
log.warn("目标端未按配置应用服务端加密,期望={},实际={}objectKey={}",
sse.expectedAlgorithm(), actual == null ? "未加密" : actual, objectKey);
}
}
@Override
public boolean exists(SyncConnection connection, String objectKey) {
return client(connection).objectExists(connection.getBucketName(), objectKey);

View File

@ -0,0 +1,112 @@
package org.dromara.sync.connector.s3;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.sync.constant.SyncConstants;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.ServerSideEncryption;
import java.util.List;
import java.util.Map;
/**
* 目标端服务端加密SSE设置
*
* <p>加密方式由连接扩展配置的 {@code sseMode} 决定取值为
* {@link SyncConstants#SSE_NONE}{@link SyncConstants#SSE_S3} {@link SyncConstants#SSE_KMS}
* 默认 {@code NONE} 表示不发送任何加密请求头完全由存储桶自身的默认加密策略决定
* 以保证对不支持 SSE 的服务端保持兼容</p>
*
* @param mode 加密方式
* @param kmsKeyId KMS 密钥标识 {@code SSE_KMS} 且需要指定密钥时使用
*/
public record S3SseSetting(String mode, String kmsKeyId) {
/**
* 支持的加密方式集合
*/
private static final List<String> SUPPORTED_MODES =
List.of(SyncConstants.SSE_NONE, SyncConstants.SSE_S3, SyncConstants.SSE_KMS);
/**
* 不指定加密方式跟随存储桶默认策略
*/
public static final S3SseSetting NONE = new S3SseSetting(SyncConstants.SSE_NONE, null);
/**
* 从连接扩展配置解析加密设置
*
* @param config 连接扩展配置
* @return 加密设置未配置时返回 {@link #NONE}
*/
public static S3SseSetting from(Map<String, Object> config) {
if (config == null || config.isEmpty()) {
return NONE;
}
String mode = normalize(config.get("sseMode"));
String kmsKeyId = StringUtils.trimToNull(stringValue(config.get("sseKmsKeyId")));
if (StringUtils.isBlank(mode)) {
mode = SyncConstants.SSE_NONE;
}
if (!SUPPORTED_MODES.contains(mode)) {
throw new ServiceException("目标连接 sseMode 只支持 {}", String.join("", SUPPORTED_MODES));
}
if (kmsKeyId != null && !SyncConstants.SSE_KMS.equals(mode)) {
throw new ServiceException("仅 SSE_KMS 加密方式可以指定 sseKmsKeyId");
}
return new S3SseSetting(mode, kmsKeyId);
}
/**
* 是否需要显式发送服务端加密请求头
*
* @return 需要发送返回 {@code true}
*/
public boolean enabled() {
return !SyncConstants.SSE_NONE.equals(mode);
}
/**
* 将加密设置应用到上传请求
*
* <p>{@code NONE} 不写入任何加密请求头由服务端按存储桶默认策略处理</p>
*
* @param builder PutObject 请求构建器
*/
public void applyTo(PutObjectRequest.Builder builder) {
switch (mode) {
case SyncConstants.SSE_S3 -> builder.serverSideEncryption(ServerSideEncryption.AES256);
case SyncConstants.SSE_KMS -> {
builder.serverSideEncryption(ServerSideEncryption.AWS_KMS);
if (StringUtils.isNotBlank(kmsKeyId)) {
builder.ssekmsKeyId(kmsKeyId);
}
}
default -> {
// NONE 不发送加密请求头保持对不支持 SSE 的服务端的兼容
}
}
}
/**
* 获取期望的服务端加密算法标识用于回写校验
*
* @return 加密算法标识{@code NONE} 返回 {@code null}
*/
public String expectedAlgorithm() {
return switch (mode) {
case SyncConstants.SSE_S3 -> ServerSideEncryption.AES256.toString();
case SyncConstants.SSE_KMS -> ServerSideEncryption.AWS_KMS.toString();
default -> null;
};
}
private static String normalize(Object value) {
String text = stringValue(value);
return text == null ? null : text.trim().toUpperCase();
}
private static String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@ -2,13 +2,22 @@ package org.dromara.sync.connector.s3;
import org.dromara.common.oss.client.DefaultOssClientImpl;
import org.dromara.common.oss.config.OssClientConfig;
import org.dromara.common.oss.exception.S3StorageException;
import org.dromara.common.oss.model.HandleAsyncResult;
import org.dromara.common.oss.model.Options;
import org.dromara.common.oss.model.PutObjectResult;
import software.amazon.awssdk.core.async.AsyncRequestBody;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.awssdk.services.s3.model.S3Exception;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
/**
* 为同步连接器补充只读的存储桶连通性探测
* 为同步连接器补充只读的存储桶连通性探测与服务端加密上传
*/
class SyncS3OssClient extends DefaultOssClientImpl {
@ -41,4 +50,64 @@ class SyncS3OssClient extends DefaultOssClientImpl {
throw e;
}
}
/**
* 上传本地文件并按连接配置附加服务端加密请求头
*
* <p>公共 OSS 模块的 {@link Options} 不支持服务端加密因此这里直接使用底层
* {@code doCustomUpload} 自行构建 PutObject 请求避免修改公共模块 API</p>
*
* @param bucket 存储桶名称
* @param key 对象键
* @param path 待上传文件
* @param options 上传选项
* @param sse 服务端加密设置
* @return 上传结果
*/
PutObjectResult upload(String bucket, String key, Path path, Options options, S3SseSetting sse) {
AsyncRequestBody body = AsyncRequestBody.fromFile(path);
// 与公共模块 bucketUpload 保持一致请求体自带的长度与类型优先于可选项
Long contentLength = body.contentLength().orElse(options.getLength());
String contentType = options.getContentType() == null || options.getContentType().isBlank()
? body.contentType() : options.getContentType();
HandleAsyncResult<PutObjectResponse> result = doCustomUpload(body, builder -> {
builder.bucket(bucket)
.key(key)
.contentMD5(options.getMd5Digest())
.contentType(contentType)
.contentLength(contentLength)
.metadata(options.getMetadata());
sse.applyTo(builder);
}, options.getTransferListeners());
if (result.isFailure()) {
throw toStorageException(result.error());
}
Optional<PutObjectResponse> opt = result.getResult();
if (opt.isEmpty()) {
throw S3StorageException.form("response is empty.");
}
PutObjectResponse response = opt.get();
Long size = response.size();
if (size == null) {
size = contentLength == null ? 0 : contentLength;
}
return PutObjectResult.form("%s/%s".formatted(config.getBucketUrl(bucket), key), key, response.eTag(), size);
}
/**
* 转换为统一的 S3 存储异常
*
* @param e 原始异常
* @return S3 存储异常
*/
private S3StorageException toStorageException(Throwable e) {
Throwable cause = e;
while ((cause instanceof CompletionException || cause instanceof ExecutionException) && cause.getCause() != null) {
cause = cause.getCause();
}
if (cause instanceof S3StorageException ex) {
return ex;
}
return S3StorageException.form(cause);
}
}

View File

@ -32,4 +32,19 @@ public interface SyncConstants {
String OBJECT_FILE = "FILE";
String OBJECT_FOLDER = "FOLDER";
String OBJECT_ONLINE_DOCUMENT = "ONLINE_DOCUMENT";
/**
* 目标端服务端加密方式不指定由存储桶默认策略决定
*/
String SSE_NONE = "NONE";
/**
* 目标端服务端加密方式SSE-S3由服务端托管密钥AES256
*/
String SSE_S3 = "SSE_S3";
/**
* 目标端服务端加密方式SSE-KMS KMS 托管密钥
*/
String SSE_KMS = "SSE_KMS";
}

View File

@ -15,6 +15,7 @@ import org.dromara.sync.connector.ConnectorRegistry;
import org.dromara.sync.connector.dingtalk.DwsAuthIdentity;
import org.dromara.sync.connector.dingtalk.DwsAuthSessionManager;
import org.dromara.sync.connector.model.ConnectorTestResult;
import org.dromara.sync.connector.s3.S3SseSetting;
import org.dromara.sync.constant.SyncConstants;
import org.dromara.sync.domain.SyncConnection;
import org.dromara.sync.domain.SyncJob;
@ -328,6 +329,8 @@ public class SyncConnectionServiceImpl implements ISyncConnectionService {
requireSecret(secrets, "accessKey", "目标连接 accessKey 不能为空");
requireSecret(secrets, "secretKey", "目标连接 secretKey 不能为空");
}
// 加密方式与 KMS 密钥组合的合法性由连接器统一定义保存时提前拦截非法配置
S3SseSetting.from(parseOptionalJson(connection.getConfigJson()));
}
/**