feat(syslog-serve): 完善微步全量拉取、探针归属控制及诊断接口
- 按 DEVICE_DEVICE.DEVICE_COLLECT_ID 过滤 API 拉取任务 - 仅允许与 APP_SERVICE_DEVICE_COLLECT_ID 匹配的节点执行任务 - 在任务扫描和数据库租约获取阶段双重校验设备归属 - 设备迁移后原节点不执行 HTTP 请求且不推进 NEXT_RUN_AT - 保留数据库租约,避免同一探针的多实例重复拉取 - 微步 searchBySql 改为全量逐条日志查询 - 移除 net_data_type=attack/risk 固定过滤 - 支持采集 attack、action、risk 并原样写入 WAL/Kafka - 保留空 sql、完整字段和时间窗口查询 - 新增微步原始查询诊断接口 - 支持自定义 size、page、pageSize、cur_page 等查询字段 - 复用任务绑定的地址、凭据、TLS 和超时配置 - 诊断查询不写 WAL/Kafka、不推进 checkpoint - 响应隐藏 API Key、Secret、签名和认证字段 - 更新启动版本标识为 WEIBU_DIAGNOSTIC_V20260917_66 - 更新 v6 部署、验证和 curl 调用文档 - Docker 基础镜像调整为 eclipse-temurin:8-jre - 补充探针归属、全量查询、诊断透传和状态隔离测试 测试: - JDK 8 - Tests run: 165 - Failures: 0 - Errors: 0 - Skipped: 2 - git diff --check 通过
This commit is contained in:
+14
-4
@@ -1,6 +1,7 @@
|
||||
package com.haobang.apipull;
|
||||
|
||||
import com.common.mapper.DeviceCollectApiMapper;
|
||||
import com.haobang.config.AppProperties;
|
||||
import com.haobang.dispatch.DeviceContextResolver;
|
||||
import com.haobang.apipull.persistence.DeviceCollectApiCheckpoint;
|
||||
import com.haobang.apipull.persistence.DeviceCollectApiConfig;
|
||||
@@ -33,6 +34,7 @@ public class ApiPullScheduler {
|
||||
private final ApiPullRunner runner;
|
||||
private final SyslogWalService wal;
|
||||
private final DeviceContextResolver deviceResolver;
|
||||
private final AppProperties appProperties;
|
||||
private ExecutorService executor;
|
||||
private Semaphore global;
|
||||
private final ConcurrentHashMap<String, Semaphore> hosts = new ConcurrentHashMap<>();
|
||||
@@ -43,12 +45,14 @@ public class ApiPullScheduler {
|
||||
DeviceCollectApiMapper mapper,
|
||||
ApiPullRunner runner,
|
||||
SyslogWalService wal,
|
||||
DeviceContextResolver deviceResolver) {
|
||||
DeviceContextResolver deviceResolver,
|
||||
AppProperties appProperties) {
|
||||
this.properties = properties;
|
||||
this.mapper = mapper;
|
||||
this.runner = runner;
|
||||
this.wal = wal;
|
||||
this.deviceResolver = deviceResolver;
|
||||
this.appProperties = appProperties;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@@ -78,7 +82,8 @@ public class ApiPullScheduler {
|
||||
@Scheduled(fixedDelayString = "${device.api.scheduler.scan-interval-ms:5000}")
|
||||
public void scan() {
|
||||
if (!properties.isEnabled()) return;
|
||||
for (DeviceCollectApiConfig c : mapper.selectDueConfigs())
|
||||
for (DeviceCollectApiConfig c
|
||||
: mapper.selectDueConfigs(appProperties.getAppServiceDeviceCollectId()))
|
||||
try {
|
||||
trySubmit(c, "SCHEDULED");
|
||||
} catch (Throwable failure) {
|
||||
@@ -146,7 +151,8 @@ public class ApiPullScheduler {
|
||||
c.getCollectTaskId(),
|
||||
c.getConfigVersion(),
|
||||
properties.getInstanceId(),
|
||||
properties.getLeaseSeconds())
|
||||
properties.getLeaseSeconds(),
|
||||
appProperties.getAppServiceDeviceCollectId())
|
||||
!= 1) {
|
||||
releaseLocal(c.getCollectTaskId(), hostPermit);
|
||||
return null;
|
||||
@@ -210,7 +216,11 @@ public class ApiPullScheduler {
|
||||
private boolean requireApiCollectionMode(DeviceCollectApiConfig c, boolean failRequest) {
|
||||
DeviceContextResolver.Resolution resolution = deviceResolver.resolveDeviceId(c.getDeviceId());
|
||||
if (resolution.isRegistered()) return true;
|
||||
mapper.scheduleNext(c.getCollectTaskId(), c.getConfigVersion(), c.getPollIntervalSeconds());
|
||||
// 非归属节点不能修改任务调度时间;设备归属可能在扫描后、执行前发生变化。
|
||||
// 手工触发失败同样不应改变正常调度计划。
|
||||
if (!failRequest && resolution.getType() != DeviceContextResolver.Type.NOT_ASSIGNED) {
|
||||
mapper.scheduleNext(c.getCollectTaskId(), c.getConfigVersion(), c.getPollIntervalSeconds());
|
||||
}
|
||||
String reason = resolution.getReason() == null ? "设备不允许 API_PULL" : resolution.getReason();
|
||||
log.warn("设备 API 拉取已被设备策略跳过: taskId={}, deviceId={}, type={}, reason={}",
|
||||
c.getCollectTaskId(), c.getDeviceId(), resolution.getType(), reason);
|
||||
|
||||
+36
@@ -7,12 +7,14 @@ import com.common.mapper.DeviceCollectApiMapper;
|
||||
import com.common.mapper.DeviceCollectTaskMapper;
|
||||
import com.common.service.DeviceDeviceService;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.haobang.apipull.adapter.AdapterException;
|
||||
import com.haobang.apipull.adapter.AdapterRegistry;
|
||||
import com.haobang.apipull.adapter.ApiPullAdapter;
|
||||
import com.haobang.apipull.adapter.PullRequest;
|
||||
import com.haobang.apipull.adapter.weibu.WeibuApiAdapter;
|
||||
import com.haobang.apipull.api.ApiPullDtos.*;
|
||||
import com.haobang.apipull.credential.CredentialProvider;
|
||||
import com.haobang.apipull.credential.CredentialSnapshot;
|
||||
@@ -409,6 +411,40 @@ public class ApiPullTaskService {
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过任务既有地址和凭据执行微步原始查询,用于现场比较不同查询及分页字段。
|
||||
* 该操作不写运行审计、WAL、Kafka 或 checkpoint。
|
||||
*/
|
||||
public WeibuDiagnosticQueryResponse diagnosticWeibuQuery(Integer taskId, JsonNode requestBody) {
|
||||
DeviceCollectApiConfig c = requireConfig(taskId);
|
||||
requireApiAllowedDevice(c.getDeviceId());
|
||||
ApiPullAdapter installed = adapters.require(c.getAdapterType());
|
||||
if (!(installed instanceof WeibuApiAdapter)) {
|
||||
throw error(
|
||||
"ADAPTER_NOT_SUPPORTED",
|
||||
"该诊断接口仅支持 WEIBU_V1 任务",
|
||||
HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
WeibuApiAdapter.DiagnosticResult result =
|
||||
((WeibuApiAdapter) installed).diagnosticQuery(c, requestBody);
|
||||
WeibuDiagnosticQueryResponse response = new WeibuDiagnosticQueryResponse();
|
||||
response.setTaskId(taskId);
|
||||
response.setDeviceId(c.getDeviceId());
|
||||
response.setConfigVersion(c.getConfigVersion());
|
||||
response.setHttpStatus(result.getHttpStatus());
|
||||
response.setDurationMs(result.getDurationMs());
|
||||
response.setResponseBytes(result.getResponseBytes());
|
||||
response.setResponse(result.getResponse());
|
||||
log.info(
|
||||
"微步 API 诊断查询完成: taskId={}, deviceId={}, httpStatus={}, durationMs={}, responseBytes={}",
|
||||
taskId,
|
||||
c.getDeviceId(),
|
||||
result.getHttpStatus(),
|
||||
result.getDurationMs(),
|
||||
result.getResponseBytes());
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 人工重置已提交窗口。 仅允许暂停且无有效租约的任务,并要求说明原因和 checkpoint 乐观锁版本。 */
|
||||
@Transactional
|
||||
public ApiPullRuntimeResponse reset(Integer taskId, ResetApiCheckpointRequest request) {
|
||||
|
||||
+146
-3
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import com.haobang.apipull.ApiPullProperties;
|
||||
import com.haobang.apipull.adapter.AdapterException;
|
||||
import com.haobang.apipull.adapter.ApiPullAdapter;
|
||||
@@ -193,13 +194,12 @@ public class WeibuApiAdapter implements ApiPullAdapter {
|
||||
|
||||
ObjectNode body = json.createObjectNode();
|
||||
body.putArray("assets_group");
|
||||
body.put("search_connection", false);
|
||||
body.putArray("net_data_type").add("attack").add("risk");
|
||||
body.put("time_from", vendorStart.getEpochSecond());
|
||||
body.put("time_to", vendorEnd.getEpochSecond());
|
||||
// 不设置 net_data_type 或 threat.level 条件:该接口中的 attack、action、risk
|
||||
// 都属于需要采集的逐条日志,具体取舍由下游设备泛化规则完成。
|
||||
body.put("sql", "");
|
||||
body.putArray("columns");
|
||||
body.put("asc", false);
|
||||
// 厂商接口只有 size,没有 offset/page。Runner 通过 total 自动缩小窗口,确保该次
|
||||
// size 足以取回窗口内全部日志,而不是把 size 当作伪分页游标。
|
||||
body.put("size", request.getLimit());
|
||||
@@ -263,6 +263,149 @@ public class WeibuApiAdapter implements ApiPullAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用任务保存的地址和凭据执行一次只读诊断查询。
|
||||
*
|
||||
* <p>请求 JSON 原样发送给微步,便于现场试验 {@code size}、{@code page}、{@code pageSize}、
|
||||
* {@code cur_page} 等不同版本可能支持的字段。调用者不能覆盖目标 URL 或认证参数;本方法也不写
|
||||
* WAL、不推进 checkpoint。请求体限制为 64 KiB,响应继续服从任务的 maxResponseBytes 和超时配置。
|
||||
*/
|
||||
public DiagnosticResult diagnosticQuery(DeviceCollectApiConfig config, JsonNode requestBody) {
|
||||
validateConfig(config);
|
||||
if (requestBody == null || !requestBody.isObject()) fail("DIAGNOSTIC_REQUEST_INVALID");
|
||||
|
||||
byte[] requestBytes = null;
|
||||
byte[] responseBytes = null;
|
||||
HttpPost post = null;
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
requestBytes = json.writeValueAsBytes(requestBody);
|
||||
if (requestBytes.length > 65536) fail("DIAGNOSTIC_REQUEST_TOO_LARGE");
|
||||
try (CredentialSnapshot snapshot =
|
||||
credentials.load(config.getCredentialRef(), TYPE, config.getDeviceId())) {
|
||||
char[] keyChars = snapshot.require("app_key").copy();
|
||||
char[] secretChars = snapshot.require("app_secret").copy();
|
||||
try {
|
||||
String apiKey = new String(keyChars);
|
||||
CredentialSchemas.validate(TYPE, apiKey, new String(secretChars), null);
|
||||
long timestamp = Instant.now().getEpochSecond();
|
||||
URI endpoint =
|
||||
new URIBuilder(endpoint(urls.validate(config.getBaseUrl())))
|
||||
.addParameter("api_key", apiKey)
|
||||
.addParameter("auth_timestamp", Long.toString(timestamp))
|
||||
.addParameter("sign", sign(apiKey, secretChars, timestamp))
|
||||
.build();
|
||||
post = new HttpPost(endpoint);
|
||||
post.setConfig(requestConfig(config));
|
||||
post.setHeader("Accept", "application/json");
|
||||
post.setEntity(
|
||||
new StringEntity(
|
||||
new String(requestBytes, StandardCharsets.UTF_8),
|
||||
ContentType.APPLICATION_JSON));
|
||||
try (CloseableHttpResponse response = http.execute(post)) {
|
||||
int status = response.getStatusLine().getStatusCode();
|
||||
responseBytes = readBounded(response.getEntity(), config.getMaxResponseBytes());
|
||||
JsonNode responseBody = parseDiagnosticBody(responseBytes);
|
||||
return new DiagnosticResult(
|
||||
status,
|
||||
elapsed(started),
|
||||
responseBytes.length,
|
||||
redactSensitiveFields(responseBody));
|
||||
}
|
||||
} finally {
|
||||
Arrays.fill(keyChars, '\0');
|
||||
Arrays.fill(secretChars, '\0');
|
||||
}
|
||||
}
|
||||
} catch (AdapterException | CredentialException e) {
|
||||
throw e;
|
||||
} catch (SocketTimeoutException | org.apache.http.conn.ConnectTimeoutException e) {
|
||||
fail("HTTP_TIMEOUT");
|
||||
return null;
|
||||
} catch (IOException e) {
|
||||
fail("DEVICE_API_UNAVAILABLE");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
fail("REQUEST_BUILD_ERROR");
|
||||
return null;
|
||||
} finally {
|
||||
if (post != null) post.releaseConnection();
|
||||
if (requestBytes != null) Arrays.fill(requestBytes, (byte) 0);
|
||||
if (responseBytes != null) Arrays.fill(responseBytes, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode parseDiagnosticBody(byte[] bytes) {
|
||||
try {
|
||||
return parseBounded(bytes);
|
||||
} catch (AdapterException invalidJson) {
|
||||
// HTTP 错误页可能不是 JSON;诊断接口仍返回受大小限制的原始文本,便于现场定位网关问题。
|
||||
return TextNode.valueOf(new String(bytes, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode redactSensitiveFields(JsonNode value) {
|
||||
JsonNode copy = value.deepCopy();
|
||||
redactSensitiveFieldsInPlace(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private void redactSensitiveFieldsInPlace(JsonNode value) {
|
||||
if (value == null) return;
|
||||
if (value.isObject()) {
|
||||
ObjectNode object = (ObjectNode) value;
|
||||
java.util.Iterator<String> names = object.fieldNames();
|
||||
java.util.List<String> fields = new java.util.ArrayList<>();
|
||||
while (names.hasNext()) fields.add(names.next());
|
||||
for (String field : fields) {
|
||||
if (isSensitiveField(field)) object.put(field, "[REDACTED]");
|
||||
else redactSensitiveFieldsInPlace(object.get(field));
|
||||
}
|
||||
} else if (value.isArray()) {
|
||||
for (JsonNode item : value) redactSensitiveFieldsInPlace(item);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSensitiveField(String field) {
|
||||
String normalized = field == null ? "" : field.toLowerCase(java.util.Locale.ROOT);
|
||||
return "api_key".equals(normalized)
|
||||
|| "app_key".equals(normalized)
|
||||
|| "app_secret".equals(normalized)
|
||||
|| "sign".equals(normalized)
|
||||
|| "authorization".equals(normalized)
|
||||
|| "token".equals(normalized);
|
||||
}
|
||||
|
||||
public static final class DiagnosticResult {
|
||||
private final int httpStatus;
|
||||
private final long durationMs;
|
||||
private final int responseBytes;
|
||||
private final JsonNode response;
|
||||
|
||||
public DiagnosticResult(int httpStatus, long durationMs, int responseBytes, JsonNode response) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.durationMs = durationMs;
|
||||
this.responseBytes = responseBytes;
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public int getHttpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
public long getDurationMs() {
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
public int getResponseBytes() {
|
||||
return responseBytes;
|
||||
}
|
||||
|
||||
public JsonNode getResponse() {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private PullPage parsePage(
|
||||
byte[] bytes,
|
||||
int status,
|
||||
|
||||
+13
@@ -1,6 +1,7 @@
|
||||
package com.haobang.apipull.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -314,6 +315,18 @@ public final class ApiPullDtos {
|
||||
private ApiPullRunResponse latestRun;
|
||||
}
|
||||
|
||||
/** 微步只读诊断结果;不包含签名 URL、API Key、Secret 或其他认证信息。 */
|
||||
@Data
|
||||
public static class WeibuDiagnosticQueryResponse {
|
||||
private Integer taskId;
|
||||
private Integer deviceId;
|
||||
private long configVersion;
|
||||
private int httpStatus;
|
||||
private long durationMs;
|
||||
private int responseBytes;
|
||||
private JsonNode response;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ApiPullRunResponse {
|
||||
private String runId;
|
||||
|
||||
+8
@@ -3,6 +3,7 @@ package com.haobang.apipull.api;
|
||||
import com.haobang.apipull.ApiPullTaskService;
|
||||
import com.haobang.apipull.api.ApiPullDtos.*;
|
||||
import com.haobang.apipull.persistence.DeviceCollectApiRun;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import javax.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -83,6 +84,13 @@ public class DeviceApiPullTaskController {
|
||||
return service.runs(taskId, page, pageSize);
|
||||
}
|
||||
|
||||
/** 请求体按原样发送给微步,便于试验现场版本支持的查询和分页字段。 */
|
||||
@PostMapping("/{taskId}/diagnostics/weibu-query")
|
||||
public WeibuDiagnosticQueryResponse diagnosticWeibuQuery(
|
||||
@PathVariable Integer taskId, @RequestBody JsonNode requestBody) {
|
||||
return service.diagnosticWeibuQuery(taskId, requestBody);
|
||||
}
|
||||
|
||||
@PostMapping("/{taskId}/checkpoint/reset")
|
||||
public ApiPullRuntimeResponse reset(
|
||||
@PathVariable Integer taskId, @Valid @RequestBody ResetApiCheckpointRequest r) {
|
||||
|
||||
Reference in New Issue
Block a user