mirror of
https://gitee.com/dromara/RuoYi-Vue-Plus.git
synced 2026-09-13 07:33:42 +08:00
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:
parent
6a3b4ed4c3
commit
d857b4b74b
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user