Compare commits

...
2 Commits
6 changed files with 258 additions and 5 deletions
+1
View File
@@ -2,6 +2,7 @@
target/ target/
*.class *.class
*.jar *.jar
dist/
# 运行时目录(本地联调用,不入 Git) # 运行时目录(本地联调用,不入 Git)
bin/ bin/
@@ -23,6 +23,7 @@ import org.noear.solon.core.handle.MethodType;
import com.hospital.front.adapter.HospitalRouter; import com.hospital.front.adapter.HospitalRouter;
import com.hospital.front.dto.Result; import com.hospital.front.dto.Result;
import com.hospital.front.infra.DbHealthMonitor;
import com.hospital.front.infra.exception.BusinessException; import com.hospital.front.infra.exception.BusinessException;
/** /**
@@ -43,6 +44,10 @@ public class AdminApi {
@Inject @Inject
private HospitalRouter router; private HospitalRouter router;
/** DB 健康缓存监测(health 接口读缓存秒回,避免 DB 慢时阻塞被 Agent 误判宕机) */
@Inject
private DbHealthMonitor dbHealthMonitor;
/** 日志目录(与 logback.xml 的 log.path 保持一致) */ /** 日志目录(与 logback.xml 的 log.path 保持一致) */
private File getLogDir() { private File getLogDir() {
return new File(Solon.cfg().get("log.path", "./log")); return new File(Solon.cfg().get("log.path", "./log"));
@@ -59,20 +64,21 @@ public class AdminApi {
// ================= 1. 健康与指标 ================= // ================= 1. 健康与指标 =================
/** 健康检查(含数据库探测 */ /** 健康检查(进程健康即时返回;DB 状态读后台探测缓存,不阻塞 */
@Mapping("/health") @Mapping("/health")
public Result<Map<String, Object>> health(Context ctx) { public Result<Map<String, Object>> health(Context ctx) {
checkToken(ctx); checkToken(ctx);
Runtime mem = Runtime.getRuntime(); Runtime mem = Runtime.getRuntime();
Result<Boolean> db = router.active().healthCheck(); Result<Boolean> db = dbHealthMonitor.last();
Map<String, Object> out = new LinkedHashMap<>(); Map<String, Object> out = new LinkedHashMap<>();
out.put("status", db.isSuccess() ? "UP" : "DEGRADED"); // db=null 表示后台首轮探测未完成(启动/唤醒初期),进程本身健康,报 STARTING 而非 DEGRADED
out.put("status", db == null ? "STARTING" : (db.isSuccess() ? "UP" : "DEGRADED"));
out.put("hospital", router.activeCode()); out.put("hospital", router.activeCode());
out.put("hospitalName", router.active().displayName()); out.put("hospitalName", router.active().displayName());
out.put("registeredHospitals", router.registeredCodes()); out.put("registeredHospitals", router.registeredCodes());
Map<String, Object> database = new HashMap<>(); Map<String, Object> database = new HashMap<>();
database.put("ok", db.isSuccess()); database.put("ok", db != null && db.isSuccess());
database.put("msg", db.getMsg()); database.put("msg", db != null ? db.getMsg() : "DB 探测进行中");
out.put("database", database); out.put("database", database);
Map<String, Object> jvm = new HashMap<>(); Map<String, Object> jvm = new HashMap<>();
jvm.put("totalMemoryMb", mem.totalMemory() / 1048576); jvm.put("totalMemoryMb", mem.totalMemory() / 1048576);
@@ -0,0 +1,45 @@
package com.hospital.front.infra;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.scheduling.annotation.Scheduled;
import com.hospital.front.adapter.HospitalRouter;
import com.hospital.front.dto.Result;
import java.util.concurrent.atomic.AtomicReference;
/**
* 数据库健康后台监测(缓存式)。
*
* 后台周期探测 DB(默认 30 秒),/api/admin/health 直接读缓存秒回——
* 避免 DB 建连慢(如机器休眠唤醒后网络未就绪,实测可达 4 分钟)时
* 健康接口长时间阻塞,导致 Agent 看门狗 30s 超时误判主程序宕机而反复重启。
*
* 启动初期缓存为空视为 STARTING(探测线程尚未跑完第一轮),不算故障。
*/
@Component
public class DbHealthMonitor {
@Inject
private HospitalRouter router;
/** 最近一次探测结果(null = 尚未完成首轮探测) */
private final AtomicReference<Result<Boolean>> last = new AtomicReference<>();
/** 周期探测(首轮延迟由调度器决定,失败不影响后续轮次) */
@Scheduled(cron = "*/30 * * * * *")
public void probe() {
try {
last.set(router.active().healthCheck());
} catch (Exception e) {
// healthCheck 内部已捕获,此处兜底防止调度线程中断
last.set(Result.error("探测异常:" + e.getMessage()));
}
}
/** 最近一次探测结果;未完成首轮探测时返回 null(调用方按 STARTING 处理) */
public Result<Boolean> last() {
return last.get();
}
}
@@ -0,0 +1,174 @@
package com.hospital.front.infra;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
import lombok.extern.slf4j.Slf4j;
/**
* IP 白名单过滤器(应对前置机端口被公网扫描器探测)。
*
* 配置(app.yml,支持医院配置覆盖):
* security:
* ipWhitelistEnabled: false # 白名单开关(默认关,行为不变)
* ipWhitelist: "10.10.10.228,10.0.0.0/24" # IP 池,逗号分隔
*
* 规则:
* - 开关关闭时全放行(默认)
* - 127.0.0.1 / ::1Agent 环回访问 /api/admin)永远放行,无需配置
* - 直连部署:取连接对端 IP(remoteIp)判定
* - nginx 反代部署(security.trustProxy=true):连接对端必须是环回或 nginx 所在 IP,
* 然后取 X-Forwarded-For 的最右侧 IP 判定(最右侧由可信 nginx 追加,无法伪造;
* 不能取最左侧——客户端可自带 XFF 冒充白名单)
* - 支持三种写法:精确 IP10.0.0.5)、通配段(112.113.*.*,每段可单独 *)、
* CIDR 网段(10.0.0.0/24
* - 非白名单一律 403 打回,并记 WARN 一行(含来源 IP 与路径,便于追踪扫描源)
*/
@Slf4j
@Component
public class IpWhitelistFilter implements Filter {
/** 环回地址(永远放行) */
private static final Set<String> LOOPBACK = new HashSet<>(Arrays.asList("127.0.0.1", "0:0:0:0:0:0:0:1", "::1"));
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
if (!enabled()) {
chain.doFilter(ctx);
return;
}
String peer = ctx.remoteIp() != null ? ctx.remoteIp() : "";
String ip = peer;
if (trustProxy()) {
// 反代模式:对端必须是环回(同机 nginx)或白名单中的代理 IP,
// 否则视为伪造直连,直接打回
if (!LOOPBACK.contains(peer)) {
log.warn("[IP白名单] 代理模式下非可信代理直连:ip={} {} {}", peer, ctx.method(), ctx.path());
reject(ctx, peer);
return;
}
// X-Forwarded-For 最右侧为本机 nginx 追加的真实客户端 IP(不可伪造)
String xff = ctx.header("X-Forwarded-For");
if (xff != null && !xff.trim().isEmpty()) {
String[] hops = xff.split(",");
ip = hops[hops.length - 1].trim();
}
// 无 XFF 的环回请求 = Agent 本机访问,保持环回 IP(放行)
}
if (isAllowed(ip)) {
chain.doFilter(ctx);
return;
}
reject(ctx, ip);
}
/** 非白名单:403 打回(一行日志,避免扫描器刷爆日志) */
private void reject(Context ctx, String ip) {
log.warn("[IP白名单] 拒绝访问:ip={} {} {}", ip, ctx.method(), ctx.path());
ctx.status(403);
ctx.contentType("application/json;charset=UTF-8");
ctx.output("{\"success\":false,\"msg\":\"禁止访问\",\"data\":null}");
}
/** 白名单开关(惰性读取,支持热更新) */
private boolean enabled() {
return "true".equals(Solon.cfg().get("security.ipWhitelistEnabled", "false"));
}
/** 代理模式开关:nginx 反代部署时置 true,从 X-Forwarded-For 取真实客户端 IP */
private boolean trustProxy() {
return "true".equals(Solon.cfg().get("security.trustProxy", "false"));
}
/** IP 是否放行:环回 / 精确匹配 / 通配段匹配 / CIDR 网段匹配 */
private boolean isAllowed(String ip) {
if (LOOPBACK.contains(ip)) {
return true;
}
for (String entry : pool()) {
if (entry.contains("/")) {
if (cidrMatch(ip, entry)) {
return true;
}
} else if (entry.contains("*")) {
if (wildcardMatch(ip, entry)) {
return true;
}
} else if (entry.equals(ip)) {
return true;
}
}
return false;
}
/**
* 通配段匹配(仅 IPv4):entry 每段可为 * 或具体数字,如 112.113.*.*。
*/
private static boolean wildcardMatch(String ip, String entry) {
String[] ipParts = ip.split("\\.");
String[] entryParts = entry.split("\\.");
if (ipParts.length != 4 || entryParts.length != 4) {
return false;
}
for (int i = 0; i < 4; i++) {
if (!"*".equals(entryParts[i]) && !entryParts[i].equals(ipParts[i])) {
return false;
}
}
return true;
}
/** 白名单 IP 池(逗号分隔,惰性读取) */
private Set<String> pool() {
String raw = Solon.cfg().get("security.ipWhitelist", "");
Set<String> set = new HashSet<>();
if (raw != null && !raw.trim().isEmpty()) {
for (String s : raw.split(",")) {
if (!s.trim().isEmpty()) {
set.add(s.trim());
}
}
}
return set;
}
/** IPv4 CIDR 网段匹配(如 10.0.0.0/24 */
private static boolean cidrMatch(String ip, String cidr) {
try {
String[] parts = cidr.split("/");
byte[] net = java.net.InetAddress.getByName(parts[0]).getAddress();
int prefix = Integer.parseInt(parts[1]);
byte[] addr = java.net.InetAddress.getByName(ip).getAddress();
if (net.length != addr.length || net.length != 4) {
return false; // 仅支持 IPv4 网段
}
int fullBytes = prefix / 8;
int restBits = prefix % 8;
for (int i = 0; i < fullBytes; i++) {
if (net[i] != addr[i]) {
return false;
}
}
if (restBits > 0 && fullBytes < 4) {
int mask = 0xFF << (8 - restBits);
if ((net[fullBytes] & mask) != (addr[fullBytes] & mask)) {
return false;
}
}
return true;
} catch (Exception e) {
return false; // 网段配置非法视为不匹配
}
}
}
@@ -23,6 +23,10 @@ public class GlobalExceptionHandler implements Filter {
public void doFilter(Context ctx, FilterChain chain) { public void doFilter(Context ctx, FilterChain chain) {
try { try {
chain.doFilter(ctx); chain.doFilter(ctx);
} catch (org.noear.solon.core.exception.StatusException e) {
// 404/405 等路由状态异常:多为扫描器探测,INFO 一行即可(不打堆栈,避免刷爆日志)
log.info("路由未匹配:{} {}HTTP {}", ctx.method(), ctx.path(), e.getCode());
renderStatus(ctx, e.getCode());
} catch (BusinessException e) { } catch (BusinessException e) {
String msg = e.getMessage() != null ? e.getMessage() : "业务异常"; String msg = e.getMessage() != null ? e.getMessage() : "业务异常";
log.warn("业务异常:{}", e.getMessage()); log.warn("业务异常:{}", e.getMessage());
@@ -42,6 +46,15 @@ public class GlobalExceptionHandler implements Filter {
} }
} }
/**
* 输出路由状态异常(404/405 等)响应。
*/
private void renderStatus(Context ctx, int code) {
ctx.status(code);
ctx.contentType("application/json;charset=UTF-8");
ctx.output("{\"success\":false,\"msg\":\"Not Found\",\"data\":null}");
}
/** /**
* 输出 Result JSON 响应(与原 hospitalmiddle 契约一致:{"success":false,"msg":...})。 * 输出 Result JSON 响应(与原 hospitalmiddle 契约一致:{"success":false,"msg":...})。
*/ */
+14
View File
@@ -44,6 +44,20 @@ order:
admin: admin:
token: "" token: ""
# IP 白名单(应对端口暴露公网被扫描器探测;环回地址 Agent 永远放行)
# 开启后非白名单 IP 一律 403。
# ipWhitelist 三种写法可混用,逗号分隔:
# 精确 IP10.0.0.5
# 通配段:112.113.*.*(每段可单独 *
# CIDR 网段:10.0.0.0/24
security:
ipWhitelistEnabled: false
ipWhitelist: ""
# ipWhitelist: "112.113.*.*,10.20.30.40,10.0.0.0/24"
# nginx 反代部署时开启:从 X-Forwarded-For 最右侧取真实客户端 IP 判定
# (对端必须是本机环回的 nginx,防止客户端伪造 XFF 绕过白名单)
# trustProxy: false
# ==================================== # ====================================
# MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入) # MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入)
# mappers 条目两种形式(Solon mybatis 约定): # mappers 条目两种形式(Solon mybatis 约定):