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:
王绍宏
2026-09-17 22:25:40 +08:00
parent 2d297eb996
commit 261f1d38e9
15 changed files with 439 additions and 36 deletions
@@ -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);
@@ -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) {
@@ -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,
@@ -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;
@@ -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) {