diff --git a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java index fa1e6ee..154eb91 100644 --- a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java +++ b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java @@ -7,18 +7,16 @@ import com.yunhai.platform.gateway.entity.OpenApiTarget; import com.yunhai.platform.gateway.repository.OpenApiLogRepository; import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; +import com.yunhai.platform.gateway.route.RouteChangeBroadcaster; import com.yunhai.platform.gateway.route.RouteRegistry; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BasePageResult; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.ClassPathResource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; -import org.springframework.http.MediaType; -import org.springframework.util.StreamUtils; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,8 +26,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.Arrays; import java.util.HashSet; @@ -38,9 +34,13 @@ import java.util.Map; import java.util.Set; /** - * 网关治理小页后端(/api/internal/admin/**,D5 最小治理面):路由/目标地址的查询与行内编辑、 - * 台账分页查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets), - * 无须重启网关。与 {@code RouteRefreshController} 同前缀,被 RoutingFilter 旁路(不进 oapi 链)。 + * 网关治理接口(/api/internal/admin/**,D5 治理面后端):路由/目标地址的查询与行内编辑、 + * 台账分页查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets) + * 并向 {@link RouteChangeBroadcaster} 发一条变更广播(其余实例收到即各自重载),无须重启网关。 + * 与 {@code RouteRefreshController} 同前缀,被 RoutingFilter 旁路(不进 oapi 链)。 + *

+ * 前端:治理面 UI 独立在 {@code open-gateway/front}(Vue3),本类不再下发 HTML + * (v1 内置 {@code static/admin.html} 已退役)。 *

* 响应契约:成功响应由 {@code yh-api-std} 的 {@code ApiResponseAdvice} 统一包成 * {@code {code:0, msg:"", data:...}}(与 notice/userbase 一致);非法入参走 {@link #badRequest} @@ -49,7 +49,8 @@ import java.util.Set; * 收网前提示:本前缀当前宿主端口可达(10013 未绑 127.0.0.1),页面用于 observe 期 * 治理与排障;T0.2 端口收网随下次重新部署自然收紧(届时仅容器网内可达)。 * 值域白名单与 {@code docs/sql/01_schema.sql} 注释对齐:visibility∈{INTERNAL,EXTERNAL}、 - * authMode∈{SIGNED,ANONYMOUS}、cacheScope∈{CALLER,GLOBAL};非法值返回 code=2 且不落库。 + * authMode∈{SIGNED,ANON}(DDL 另定义 OPTIONAL,本期不开放——避免"配得出但语义未实现")、 + * cacheScope∈{CALLER,GLOBAL};非法值返回 code=2 且不落库。 */ @Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账") @RestController @@ -57,7 +58,7 @@ import java.util.Set; public class InternalAdminController { private static final Set VISIBILITY = new HashSet<>(Arrays.asList("INTERNAL", "EXTERNAL")); - private static final Set AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANONYMOUS")); + private static final Set AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANON")); private static final Set CACHE_SCOPE = new HashSet<>(Arrays.asList("CALLER", "GLOBAL")); @Autowired @@ -72,6 +73,10 @@ public class InternalAdminController { @Autowired private RouteRegistry routeRegistry; + /** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */ + @Autowired(required = false) + private RouteChangeBroadcaster broadcaster; + /** 测试/显式装配入口 */ void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo, OpenApiLogRepository logRepo, RouteRegistry routeRegistry) { @@ -81,14 +86,6 @@ public class InternalAdminController { this.routeRegistry = routeRegistry; } - /** 治理小页本体(classpath:/static/admin.html,N03 产出);返回 String 故不被信封包装 */ - @Operation(operationId = "GatewayAdmin.page", summary = "治理小页(内置 HTML)") - @GetMapping(value = "/page", produces = MediaType.TEXT_HTML_VALUE) - public String page() throws IOException { - return StreamUtils.copyToString( - new ClassPathResource("static/admin.html").getInputStream(), StandardCharsets.UTF_8); - } - /** 路由表分页(治理对象全量含禁用行;targetCode+pathPattern 稳定排序) */ @Operation(operationId = "GatewayAdmin.routes", summary = "路由注册表分页") @GetMapping("/routes") @@ -156,6 +153,7 @@ public class InternalAdminController { routeRepo.save(route); Map out = new LinkedHashMap<>(); out.put("loaded", routeRegistry.reload()); + broadcast(); return out; } @@ -191,9 +189,17 @@ public class InternalAdminController { targetRepo.save(target); Map out = new LinkedHashMap<>(); out.put("targets", routeRegistry.reloadTargets()); + broadcast(); return out; } + /** 变更广播:本实例已就地重载,其余实例靠广播同步(发布失败只记 WARN,不影响本次写操作) */ + private void broadcast() { + if (broadcaster != null) { + broadcaster.publish(); + } + } + /** * 台账倒序分页查询。{@code pageSize} 优先、上限 500;兼容旧 {@code limit} 参数 * (未传 pageSize 时以 limit 作 pageSize)。targetCode/callerAppKey 可空=不过滤。 diff --git a/src/main/java/com/yunhai/platform/gateway/auth/AnonRateLimiter.java b/src/main/java/com/yunhai/platform/gateway/auth/AnonRateLimiter.java new file mode 100644 index 0000000..81dabf1 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/auth/AnonRateLimiter.java @@ -0,0 +1,32 @@ +package com.yunhai.platform.gateway.auth; + +/** + * ANON 路由的 per-IP 限流判定钩子(T3.5 约束:「ANON 的风险面就是匿名,必须限」)。 + *

+ * 契约: + *

+ * 本期(T3.1 Redis per-IP 计数未落地)唯一实现是 {@link UnavailableAnonRateLimiter}: + * 因此即便在治理页把路由配成 {@code EXTERNAL × ANON},也不会免签放行——这是有意为之 + * (落地总纲 D4:未接限流的匿名口子等于无限流暴露面)。T3.1 落地时以新实现替换该 bean 即可。 + */ +public interface AnonRateLimiter { + + enum Decision { + /** 限流内:放行 */ + ALLOW, + /** 超出限额:裸 429 */ + EXCEEDED, + /** 限流不可用:fail-closed,裸 403 */ + UNAVAILABLE + } + + /** + * @param clientIp 客户端来源地址({@code remoteAddr}) + * @param qps 限额;调用方已保证 > 0(route.rate_limit_qps 为 0 时取全局缺省) + */ + Decision tryAcquire(String clientIp, int qps); +} diff --git a/src/main/java/com/yunhai/platform/gateway/auth/EntryDetector.java b/src/main/java/com/yunhai/platform/gateway/auth/EntryDetector.java new file mode 100644 index 0000000..e4daa4f --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/auth/EntryDetector.java @@ -0,0 +1,138 @@ +package com.yunhai.platform.gateway.auth; + +import javax.servlet.http.HttpServletRequest; +import java.util.List; + +/** + * 入口来源判定(T4.3):判定一次请求是否走「外部入口」。 + *

+ * 外部入口 = 来源可信({@code remoteAddr} 落在 {@code yh.gateway.trusted-proxies} + * 的某个 CIDR 内)且 带 {@code X-Entry: external}(大小写与首尾空白不敏感)。 + * 两条件缺一即按内部入口处理——来源不可信时客户端伪造的 {@code X-Entry} 天然无效 + * (A6),该头本身也不会转发给提供方({@code StreamingForwarder} 剥除), + * 故提供方永远看不到标记头。 + *

+ * 信任列表为空(默认)⇒ 不存在外部入口,全站按内部口径(一律验签)。 + * 仅识别 IPv4(含 {@code ::ffff:a.b.c.d} 映射形态);IPv6 与非法 CIDR 一律判不可信 + * (fail-closed,绝不因为"解析不了"而放行)。 + */ +public final class EntryDetector { + + /** Caddy 在 open 域 server 块标记的来源头(T4.3) */ + public static final String HEADER_ENTRY = "X-Entry"; + + /** 标记值(比较时大小写不敏感) */ + public static final String ENTRY_EXTERNAL = "external"; + + private EntryDetector() { + } + + /** + * @param trustedProxies 可信来源 CIDR 列表({@code yh.gateway.trusted-proxies}) + * @return true = 外部入口(可见性/免签判定仅对这个入口生效) + */ + public static boolean isExternal(HttpServletRequest req, List trustedProxies) { + String marker = req.getHeader(HEADER_ENTRY); + if (marker == null || !ENTRY_EXTERNAL.equalsIgnoreCase(marker.trim())) { + return false; + } + return inAnyCidr(req.getRemoteAddr(), trustedProxies); + } + + /** 来源 IP 是否落在任一 CIDR 内;空列表 / 解析失败 = false */ + static boolean inAnyCidr(String ip, List cidrs) { + byte[] addr = parseIpv4(ip); + if (addr == null || cidrs == null) { + return false; + } + for (String cidr : cidrs) { + if (matches(addr, cidr)) { + return true; + } + } + return false; + } + + /** 单条 CIDR;无 {@code /} 时按 /32 精确地址比较 */ + private static boolean matches(byte[] addr, String cidr) { + if (cidr == null) { + return false; + } + String spec = cidr.trim(); + if (spec.isEmpty()) { + return false; + } + int slash = spec.indexOf('/'); + String network = slash < 0 ? spec : spec.substring(0, slash); + int prefix = 32; + if (slash >= 0) { + try { + prefix = Integer.parseInt(spec.substring(slash + 1).trim()); + } catch (NumberFormatException e) { + return false; + } + if (prefix < 0 || prefix > 32) { + return false; + } + } + byte[] net = parseIpv4(network); + if (net == null) { + return false; + } + int fullBytes = prefix / 8; + for (int i = 0; i < fullBytes; i++) { + if (addr[i] != net[i]) { + return false; + } + } + int restBits = prefix % 8; + if (restBits == 0) { + return true; + } + int mask = (0xFF << (8 - restBits)) & 0xFF; + return (addr[fullBytes] & mask) == (net[fullBytes] & mask); + } + + /** 只认 IPv4 点分十进制;{@code ::ffff:a.b.c.d} 剥前缀后按 IPv4 处理,其余(纯 IPv6 等)返回 null */ + private static byte[] parseIpv4(String raw) { + if (raw == null) { + return null; + } + String ip = raw.trim(); + if (ip.isEmpty()) { + return null; + } + int lastColon = ip.lastIndexOf(':'); + if (lastColon >= 0) { + String tail = ip.substring(lastColon + 1); + if (!ip.toLowerCase().startsWith("::ffff:") || tail.indexOf('.') < 0) { + return null; + } + ip = tail; + } + String[] parts = ip.split("\\.", -1); + if (parts.length != 4) { + return null; + } + byte[] out = new byte[4]; + for (int i = 0; i < 4; i++) { + String p = parts[i]; + if (p.isEmpty() || p.length() > 3) { + return null; + } + int v = 0; + for (int j = 0; j < p.length(); j++) { + char c = p.charAt(j); + if (c < '0' || c > '9') { + return null; + } + v = v * 10 + (c - '0'); + } + if (v > 255) { + return null; + } + out[i] = (byte) v; + } + return out; + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/auth/UnavailableAnonRateLimiter.java b/src/main/java/com/yunhai/platform/gateway/auth/UnavailableAnonRateLimiter.java new file mode 100644 index 0000000..9cbd8bd --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/auth/UnavailableAnonRateLimiter.java @@ -0,0 +1,34 @@ +package com.yunhai.platform.gateway.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; + +/** + * ANON 限流的占位实现(T3.1 未落地期间的 fail-closed 形态):一律返回 + * {@link Decision#UNAVAILABLE},故 {@code EXTERNAL × ANON} 路由不会被免签放行。 + *

+ * 为什么不是"先放行、后补限流":匿名入口一旦无限制开放,暴露面在限流落地前就是全开的, + * 且期间无法回收(第三方可能已经接入)。宁可先不给,也不留无限流的口子。 + *

+ * T3.1 落地方式:新增 Redis per-IP 计数的 {@link AnonRateLimiter} 实现并让它优先装配 + * (替换本 bean 或加 {@code @Primary}),本类即自动退场;{@code SignVerifyFilter} 无需改动。 + */ +@Component +public class UnavailableAnonRateLimiter implements AnonRateLimiter { + + private static final Logger log = LoggerFactory.getLogger(UnavailableAnonRateLimiter.class); + + @PostConstruct + void announce() { + log.warn("[GatewayAnon] ANON 限流实现未就绪(T3.1 per-IP 计数待落地):" + + "EXTERNAL×ANON 路由一律 fail-closed 拒绝(403),不会免签放行"); + } + + @Override + public Decision tryAcquire(String clientIp, int qps) { + return Decision.UNAVAILABLE; + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java index dbd5aa9..d2f4c04 100644 --- a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java +++ b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java @@ -1,8 +1,11 @@ package com.yunhai.platform.gateway.filter; +import com.yunhai.platform.gateway.auth.AnonRateLimiter; +import com.yunhai.platform.gateway.auth.EntryDetector; import com.yunhai.platform.gateway.auth.GatewayKeyService; import com.yunhai.platform.gateway.envelope.EnvelopeWriter; import com.yunhai.platform.gateway.route.GatewayProperties; +import com.yunhai.platform.gateway.route.RouteSpec; import com.yunhai.platform.utils.crypto.SignUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.Order; @@ -24,18 +27,27 @@ import java.util.Collections; import java.util.List; /** - * v1 验签过滤器(T1.2,共享向量):canonical 与 starter + * 入口分流 + v1 验签过滤器(T1.2 / T4.3),链序第 3 环(T1.6 定型): + * Sanitize(1) → 路由判定(2) → 本过滤器(3) → 身份注入(4) → 转发(5)。 + *

+ * 先分入口,再分策略(落地总纲 §0.1 判定矩阵): + *

+ * 外部入口的 403/429 属策略层拒绝,与 {@code observe} 正交(同 400/404:策略与协议错误永不放行)。 + *

+ * 验签口径(T1.2,共享向量):canonical 与 starter * {@code AppAuthFilter.buildSignedTarget + SignCanonical} 逐字节一致—— * {@code METHOD\n剥掉 target 首段后的 path[?解码排序query]\ntimestamp},HmacSHA256-Base64(F1), * 与客户端"剥 basePath 后签名"严格对齐;比对复用 starter 侧同一 {@link SignUtil}(不重写算法), - * 时间窗 ±5min(F2,不得更宽)。 - *

- * 头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = fail-closed)/ 验签失败 / - * 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)放行(R4), - * observe=false 裸 401(F5 契约)。判定结果经 request attribute 传递 + * 时间窗 ±5min(F2,不得更宽)。头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = + * fail-closed)/ 验签失败 / 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED) + * 放行(R4),observe=false 裸 401(F5 契约)。判定结果经 request attribute 传递 * ({@link #ATTR_VERDICT} / {@link #ATTR_AUTH_APPKEY}),供 T1.4 身份注入与 T1.5 台账消费。 - *

- * 链序第 3 环(T1.6 定型):Sanitize(1) → 路由判定(2) → 本过滤器(3) → 身份注入(4) → 转发(5)。 */ @Component @Order(3) @@ -51,12 +63,21 @@ public class SignVerifyFilter implements Filter { public static final String VERDICT_FAIL_OBSERVED = "FAIL_OBSERVED"; public static final String VERDICT_ANON = "ANON"; + /** 路由可见性:仅外部入口可达 */ + private static final String VISIBILITY_EXTERNAL = "EXTERNAL"; + /** 路由鉴权模式:免签(必须配限流;本期限流未就绪 = fail-closed 拒绝) */ + private static final String AUTH_MODE_ANON = "ANON"; + @Autowired private GatewayKeyService keyService; @Autowired private GatewayProperties properties; + /** ANON 限流判定;缺省装配的是 {@link com.yunhai.platform.gateway.auth.UnavailableAnonRateLimiter}(fail-closed) */ + @Autowired(required = false) + private AnonRateLimiter anonRateLimiter; + /** 测试/显式装配入口(同 TargetRouter.setProperties 惯例) */ void setKeyService(GatewayKeyService keyService) { this.keyService = keyService; @@ -66,6 +87,10 @@ public class SignVerifyFilter implements Filter { this.properties = properties; } + void setAnonRateLimiter(AnonRateLimiter anonRateLimiter) { + this.anonRateLimiter = anonRateLimiter; + } + @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { @@ -79,7 +104,51 @@ public class SignVerifyFilter implements Filter { chain.doFilter(request, response); // 网关自身端点不经 oapi 验签 return; } + if (EntryDetector.isExternal(req, properties.getTrustedProxies())) { + externalBranch(req, res, chain); + return; + } + verifyBranch(req, res, chain); // 内部入口:一律验签(不看 visibility / authMode) + } + /** + * 外部入口:按注册表策略分流。{@code spec} 缺省(未经 RoutingFilter 的异常路径)按最严处理, + * 且不可见 = 拒绝——外部入口永远不"默认放行"。 + */ + private void externalBranch(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + RouteSpec spec = (RouteSpec) req.getAttribute(RoutingFilter.ATTR_ROUTE_SPEC); + if (spec == null || !VISIBILITY_EXTERNAL.equals(spec.visibility)) { + req.setAttribute(ATTR_VERDICT, VERDICT_FAIL); + EnvelopeWriter.writeBare(res, 403); + return; + } + if (AUTH_MODE_ANON.equals(spec.authMode)) { + anonBranch(req, res, chain, spec); + return; + } + verifyBranch(req, res, chain); // EXTERNAL×SIGNED(OPTIONAL 本期未开放,落到此处亦按 SIGNED) + } + + /** EXTERNAL×ANON:限流内免签放行(不注入身份头 = 匿名就是匿名);限流不可用同样拒绝(D4 fail-closed) */ + private void anonBranch(HttpServletRequest req, HttpServletResponse res, FilterChain chain, RouteSpec spec) + throws IOException, ServletException { + int qps = spec.rateLimitQps > 0 ? spec.rateLimitQps : properties.getAnonPerIpQps(); + AnonRateLimiter.Decision decision = anonRateLimiter == null + ? AnonRateLimiter.Decision.UNAVAILABLE + : anonRateLimiter.tryAcquire(req.getRemoteAddr(), qps); + if (decision == AnonRateLimiter.Decision.ALLOW) { + req.setAttribute(ATTR_VERDICT, VERDICT_ANON); + chain.doFilter(req, res); + return; + } + req.setAttribute(ATTR_VERDICT, VERDICT_FAIL); + EnvelopeWriter.writeBare(res, decision == AnonRateLimiter.Decision.EXCEEDED ? 429 : 403); + } + + /** 验签分支:内部入口恒走此路,外部入口的 EXTERNAL×SIGNED 亦走此路 */ + private void verifyBranch(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { boolean pass = false; String appKey = req.getHeader("X-App-Key"); String timestamp = req.getHeader("X-Timestamp"); @@ -101,13 +170,13 @@ public class SignVerifyFilter implements Filter { if (pass) { req.setAttribute(ATTR_VERDICT, VERDICT_PASS); req.setAttribute(ATTR_AUTH_APPKEY, appKey); - chain.doFilter(request, response); + chain.doFilter(req, res); return; } if (properties.isObserve()) { // R4:P1 只观察——记台账不拦(台账写入由 T1.5/T1.6 消费本 attribute) req.setAttribute(ATTR_VERDICT, VERDICT_FAIL_OBSERVED); - chain.doFilter(request, response); + chain.doFilter(req, res); } else { req.setAttribute(ATTR_VERDICT, VERDICT_FAIL); EnvelopeWriter.writeBare(res, 401); diff --git a/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java b/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java index 218535c..09c6ac4 100644 --- a/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java +++ b/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java @@ -1,5 +1,6 @@ package com.yunhai.platform.gateway.forward; +import com.yunhai.platform.gateway.auth.EntryDetector; import com.yunhai.platform.gateway.envelope.EnvelopeWriter; import org.apache.http.HttpEntity; import org.apache.http.Header; @@ -41,7 +42,8 @@ import java.util.concurrent.TimeUnit; *

  • 读超时默认 30s({@link #DEFAULT_READ_TIMEOUT_MS};route.timeout_ms 覆盖,本期注册表不配 = 统一默认);
  • *
  • 请求头:保留 {@code X-App-Key / X-Timestamp / X-Signature / X-Nonce} 原样透传 * (UserBase 自验签依赖,R3);剥除客户端伪造的 {@code X-Caller-App-Key}(身份头唯一合法来源是 - * 网关,T1.4 注入);剥除 hop-by-hop 头(Connection/Keep-Alive/Transfer-Encoding/Content-Length + * 网关,T1.4 注入)与入口标记头 {@code X-Entry}(T4.3,网关与 Caddy 之间的约定); + * 剥除 hop-by-hop 头(Connection/Keep-Alive/Transfer-Encoding/Content-Length * 由客户端库重算);
  • *
  • body 上限 route.request_body_limit_mb(默认 10MB),超限裸 413;
  • *
  • 响应 status/headers/body 原样透传(不经 EnvelopeWriter);连接失败 → 裸 502, @@ -70,7 +72,10 @@ public class StreamingForwarder { Set s = new HashSet<>(); Collections.addAll(s, "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", - "content-length", "host", HEADER_CALLER_APP_KEY.toLowerCase()); + "content-length", "host", HEADER_CALLER_APP_KEY.toLowerCase(), + // T4.3:入口标记头是网关与 Caddy 之间的约定,提供方不需要也不该看到—— + // 一律剥除(伪造值自然也到不了提供方),网关内部靠 request attribute 传递判定结果 + EntryDetector.HEADER_ENTRY.toLowerCase()); REQ_SKIP = s; Set r = new HashSet<>(); Collections.addAll(r, "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", diff --git a/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java b/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java index 4bb871a..c3c96f5 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java +++ b/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java @@ -3,7 +3,9 @@ package com.yunhai.platform.gateway.route; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -11,7 +13,10 @@ import java.util.Map; *
      *
    • routes:target → base-url(容器网内服务直连 URL,如 http://userbase-service:8080; * target = F11 七个生产 ctx);
    • - *
    • observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4)。
    • + *
    • observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4);
    • + *
    • trustedProxies:外部入口判定的可信来源(T4.3,CIDR 列表)——仅这些来源的 + * {@code X-Entry: external} 生效;留空 = 不存在外部入口;
    • + *
    • anonPerIpQps:ANON 路由未配 {@code rate_limit_qps} 时的 per-IP 缺省限额(T3.1 消费)。
    • *
    */ @Component @@ -24,6 +29,16 @@ public class GatewayProperties { /** 观察模式:true = 全判定只记不拦(P1 只观察,R4) */ private boolean observe = true; + /** + * 可信来源列表(CIDR,如 {@code 172.16.0.0/12})。只有来自这些地址且带 + * {@code X-Entry: external} 的请求才算外部入口——否则一律按内部口径验签。 + * 默认空 = 外部入口不成立(最严,客户端伪造 X-Entry 无效)。 + */ + private List trustedProxies = new ArrayList<>(); + + /** ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(>0) */ + private int anonPerIpQps = 10; + public Map getRoutes() { return routes; } @@ -39,4 +54,20 @@ public class GatewayProperties { public void setObserve(boolean observe) { this.observe = observe; } + + public List getTrustedProxies() { + return trustedProxies; + } + + public void setTrustedProxies(List trustedProxies) { + this.trustedProxies = trustedProxies == null ? new ArrayList<>() : trustedProxies; + } + + public int getAnonPerIpQps() { + return anonPerIpQps; + } + + public void setAnonPerIpQps(int anonPerIpQps) { + this.anonPerIpQps = anonPerIpQps; + } } diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java b/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java new file mode 100644 index 0000000..7d44f2f --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java @@ -0,0 +1,97 @@ +package com.yunhai.platform.gateway.route; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; + +/** + * 注册表变更广播(D1 选型 C 的后半):治理页/刷新端点写库后就地 reload 之外, + * 再向 {@link #CHANNEL} 发一条消息,各实例收到即各自 {@link RouteRegistry#reload()} / + * {@link RouteRegistry#reloadTargets()},免重启、免逐实例点「重载注册表」。 + *

    + * Redis 在这里的角色是「变更通知」,不是判定主存:路由判定始终读本地 Caffeine(O(1) 不查库), + * 故 Redis 异常时各实例停留在本地快照(stale 但准确),绝不会出现「Redis 挂了就放行」—— + * 广播通道断了最多是"改动晚一点生效"。 + *

    + * 失败口径:发布失败只记 WARN(本实例已重载成功,其余实例可由治理页「重载注册表」或重启兜底); + * 订阅装配失败退化为单实例语义,判定能力不受影响。开关 {@code yh.gateway.redis-broadcast-enabled} + * 默认开,测试上下文(无 Redis)显式关掉。 + */ +@Component +@ConditionalOnProperty(prefix = "yh.gateway", name = "redis-broadcast-enabled", + havingValue = "true", matchIfMissing = true) +public class RouteChangeBroadcaster implements MessageListener, DisposableBean { + + /** 变更频道名;消息体 = epoch 毫秒,仅作触发信号(内容不参与任何判定) */ + public static final String CHANNEL = "gw:route:version"; + + private static final Logger log = LoggerFactory.getLogger(RouteChangeBroadcaster.class); + + @Autowired + private RouteRegistry routeRegistry; + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private RedisConnectionFactory connectionFactory; + + private RedisMessageListenerContainer container; + + @PostConstruct + void subscribe() { + try { + RedisMessageListenerContainer c = new RedisMessageListenerContainer(); + c.setConnectionFactory(connectionFactory); + c.addMessageListener(this, new ChannelTopic(CHANNEL)); + c.afterPropertiesSet(); // 连接不可用时异步重连,不阻塞启动 + container = c; + log.info("[GatewayRoute] 变更广播已订阅 channel={}", CHANNEL); + } catch (RuntimeException e) { + log.warn("[GatewayRoute] 变更广播订阅装配失败,退化为单实例语义(判定不受影响):{}", e.getMessage()); + } + } + + /** 写库调用方在本地 reload 之后调用;失败不抛出(写操作本身已生效) */ + public void publish() { + try { + redisTemplate.convertAndSend(CHANNEL, String.valueOf(System.currentTimeMillis())); + } catch (RuntimeException e) { + log.warn("[GatewayRoute] 变更广播发送失败(本实例已重载,其余实例未同步):{}", e.getMessage()); + } + } + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + routeRegistry.reload(); + routeRegistry.reloadTargets(); + log.info("[GatewayRoute] 收到变更广播,本实例注册表已重载"); + } catch (RuntimeException e) { + // 重载失败 = 沿用上一次快照(仍是准确的最严策略,不会放行) + log.warn("[GatewayRoute] 广播触发的重载失败,沿用上一次快照:{}", e.getMessage()); + } + } + + @Override + public void destroy() { + if (container != null) { + try { + container.destroy(); // DisposableBean 契约:关闭期异常无处上报,吞掉即可 + } catch (Exception ignored) { + // 关闭期异常无需处理 + } + } + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java index 262dc5f..ac2850a 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java @@ -13,6 +13,9 @@ import java.util.Map; /** * 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达, * 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。 + *

    + * 重载后向 {@link RouteChangeBroadcaster} 发一条变更广播:本实例已就地重载, + * 其余实例收到广播各自重载(多实例部署下不需要逐实例点「重载注册表」)。 */ @Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账") @RestController @@ -22,6 +25,10 @@ public class RouteRefreshController { @Autowired private RouteRegistry routeRegistry; + /** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */ + @Autowired(required = false) + private RouteChangeBroadcaster broadcaster; + /** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,E11 同端点重载) */ @Operation(operationId = "GatewayAdmin.refresh", summary = "热重载路由注册表与目标地址") @PostMapping("/routes/refresh") @@ -29,6 +36,9 @@ public class RouteRefreshController { Map body = new LinkedHashMap<>(); body.put("loaded", routeRegistry.reload()); body.put("targets", routeRegistry.reloadTargets()); + if (broadcaster != null) { + broadcaster.publish(); + } return body; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ca51fcf..67e837d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,3 +59,13 @@ yh: # notice: http://notice-service:8080 # 观察模式(T1.6):true = 全判定只记不拦(R4:P1 只观察);生产初期保持 true observe: true + # 外部入口判定的可信来源(T4.3):只有来自这些地址(CIDR,逗号分隔)且带 + # X-Entry: external 的请求才按外部入口处理——其余一律按内部口径(一律验签)。 + # 留空/不配 = 不存在外部入口(最严;客户端伪造 X-Entry 天然无效)。 + # 上线时由平台配置层注入 Caddy 所在网段(Caddy 在本网段向上游打 X-Entry)。 + # trusted-proxies: + # - 172.16.0.0/12 + # ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(T3.1 消费) + # anon-per-ip-qps: 10 + # 注册表变更广播(多实例同步治理页改动):关掉退化为单实例语义,判定不受影响 + # redis-broadcast-enabled: true diff --git a/src/main/resources/static/admin.html b/src/main/resources/static/admin.html deleted file mode 100644 index 1f3d09c..0000000 --- a/src/main/resources/static/admin.html +++ /dev/null @@ -1,252 +0,0 @@ - - - - - -网关治理 - - - -

    -

    网关治理

    - - - -
    -
    - -
    -

    路由治理旋钮 - 值域 visibility=INTERNAL/EXTERNAL · authMode=SIGNED/ANONYMOUS · cacheScope=CALLER/GLOBAL

    -
    - - - - - - - -
    idtargetpathmethodvisibilityauthModecacheTtlcacheScopeqps启用备注
    -
    - -
    -

    目标地址

    -
    - - -
    idtargetbase_url启用更新时间
    -
    - -
    -

    访问台账

    -
    - - - - - -
    -
    - - - -
    id时间callertargetmethodpathstatusbackendcost(ms)
    -
    - -
    -
    - - - - diff --git a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java index 99749f8..490ca38 100644 --- a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java +++ b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java @@ -28,13 +28,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** - * 治理小页后端(N02 TDD):page/routes/targets/logs 查询形态 + 路由/目标行内编辑的 + * 治理面后端(N02 TDD):routes/targets/logs 查询形态 + 路由/目标行内编辑的 * 值域闸(非法值不落库)+ 写后热重载调用。MockMvc standalone + mock 仓储/注册表。 *

    * 契约说明(B3/B4 后):查询接口返回 {@code BasePageResult}({@code {total, content, ...}}), * 非法入参返回 HTTP 200 + {@code {code:2, msg}}(平台规范)。standalone 模式不装配 * {@code yh-api-std} 的 {@code ApiResponseAdvice},故成功响应在本测试中保持裸结构; * 线上由 advice 统一再包一层 {@code {code,msg,data}}(分页体落在 {@code data})。 + *

    + * v1 内置 HTML({@code /api/internal/admin/page} + static/admin.html)已退役: + * 治理 UI 独立在 {@code open-gateway/front},故此处不再有用例。 */ class InternalAdminControllerTest { @@ -65,16 +68,6 @@ class InternalAdminControllerTest { .thenReturn(new PageImpl<>(Arrays.asList(logRow()))); } - @Test - void page_servesHtml() throws Exception { - mvc.perform(get("/api/internal/admin/page")) - .andExpect(status().isOk()) - .andExpect(result -> { - String body = result.getResponse().getContentAsString(); - org.assertj.core.api.Assertions.assertThat(body).contains("且 带 X-Entry: external。 + * 覆盖 CIDR 边界、映射地址形态、非法/空缺配置的 fail-closed 行为——伪造 X-Entry(来源不可信) + * 必须无效,这是"客户端不能自封为外部入口"的唯一保证(A6)。 + */ +class EntryDetectorTest { + + private static final List DOCKER_NET = Arrays.asList("172.16.0.0/12"); + + private static MockHttpServletRequest request(String remoteAddr, String entryHeader) { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x"); + req.setRemoteAddr(remoteAddr); + if (entryHeader != null) { + req.addHeader(EntryDetector.HEADER_ENTRY, entryHeader); + } + return req; + } + + @Test + void 可信来源带标记_判定外部入口() { + assertThat(EntryDetector.isExternal(request("172.18.0.2", "external"), DOCKER_NET)).isTrue(); + } + + @Test + void 标记大小写与首尾空白不敏感() { + assertThat(EntryDetector.isExternal(request("172.18.0.2", " EXTERNAL "), DOCKER_NET)).isTrue(); + assertThat(EntryDetector.isExternal(request("172.18.0.2", "External"), DOCKER_NET)).isTrue(); + } + + @Test + void 来源不可信_伪造标记无效() { + // 公网来源自带 X-Entry: external —— 必须仍按内部入口处理 + assertThat(EntryDetector.isExternal(request("203.0.113.9", "external"), DOCKER_NET)).isFalse(); + } + + @Test + void 无标记或有其他标记_仍为内部入口() { + assertThat(EntryDetector.isExternal(request("172.18.0.2", null), DOCKER_NET)).isFalse(); + assertThat(EntryDetector.isExternal(request("172.18.0.2", "internal"), DOCKER_NET)).isFalse(); + } + + @Test + void 信任列表为空_不存在外部入口() { + assertThat(EntryDetector.isExternal(request("172.18.0.2", "external"), new ArrayList<>())).isFalse(); + assertThat(EntryDetector.isExternal(request("172.18.0.2", "external"), null)).isFalse(); + } + + @Test + void cidr边界_网段内命中网段外不命中() { + assertThat(EntryDetector.inAnyCidr("172.16.0.0", DOCKER_NET)).as("起始地址").isTrue(); + assertThat(EntryDetector.inAnyCidr("172.31.255.255", DOCKER_NET)).as("末地址").isTrue(); + assertThat(EntryDetector.inAnyCidr("172.15.255.255", DOCKER_NET)).as("前一位").isFalse(); + assertThat(EntryDetector.inAnyCidr("172.32.0.1", DOCKER_NET)).as("后一位").isFalse(); + } + + @Test + void 精确地址与全匹配与斜零前缀() { + assertThat(EntryDetector.inAnyCidr("10.0.0.7", Arrays.asList("10.0.0.7"))).as("无 / 按精确地址").isTrue(); + assertThat(EntryDetector.inAnyCidr("10.0.0.8", Arrays.asList("10.0.0.7"))).isFalse(); + assertThat(EntryDetector.inAnyCidr("8.8.8.8", Arrays.asList("0.0.0.0/0"))).as("/0 全通").isTrue(); + assertThat(EntryDetector.inAnyCidr("10.1.2.3", Arrays.asList("10.0.0.0/8", "10.1.0.0/16"))).as("多值命中一项即可").isTrue(); + } + + @Test + void 列表中非法项不影响合法项命中() { + assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("bogus", "172.16.0.0/12"))).isTrue(); + } + + @Test + void 映射地址形态按IPv4匹配() { + assertThat(EntryDetector.inAnyCidr("::ffff:172.18.0.2", DOCKER_NET)).isTrue(); + } + + @Test + void 非法配置与IPv6一律不可信() { + assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("172.16.0.0/33"))).as("前缀越界").isFalse(); + assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("172.16.0.0/abc"))).as("前缀非数字").isFalse(); + assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("172.16.0"))).as("非法网络段").isFalse(); + assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList(" "))).as("空白项").isFalse(); + assertThat(EntryDetector.inAnyCidr("fe80::1", Arrays.asList("::/0"))).as("纯 IPv6 不支持").isFalse(); + assertThat(EntryDetector.inAnyCidr(null, DOCKER_NET)).isFalse(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterEntryTest.java b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterEntryTest.java new file mode 100644 index 0000000..9b09f4f --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterEntryTest.java @@ -0,0 +1,196 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.auth.AnonRateLimiter; +import com.yunhai.platform.gateway.auth.EntryDetector; +import com.yunhai.platform.gateway.auth.GatewayKeyService; +import com.yunhai.platform.gateway.route.GatewayProperties; +import com.yunhai.platform.gateway.route.RouteSpec; +import com.yunhai.platform.utils.crypto.SignUtil; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.ArrayList; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 入口×策略判定矩阵用例(T4.3 / 落地总纲 §0.1,验收 A1-A6): + *

      + *
    • A1 内部入口(无标记)—— 任意路由一律验签,不看 {@code visibility}/{@code authMode};
    • + *
    • A2 外部入口打 {@code INTERNAL} 路由 —— 裸 403(且不因 observe=true 而放行);
    • + *
    • A3 外部入口打 {@code EXTERNAL×SIGNED} 无签名 —— 裸 401;
    • + *
    • A4 外部入口打 {@code EXTERNAL×ANON} 限流内 —— 免签放行,verdict=ANON、不注入身份头;
    • + *
    • A5 同上超限 —— 裸 429;限流未就绪 —— 裸 403(fail-closed,D4);
    • + *
    • A6 来源不可信时伪造 {@code X-Entry: external} —— 无效,仍按内部入口处理。
    • + *
    + */ +class SignVerifyFilterEntryTest { + + private static final String SECRET = "sec-entry-0123456789abcdef"; + + private static final RouteSpec INTERNAL_SIGNED = new RouteSpec("INTERNAL", "SIGNED", 0, "CALLER", 0); + private static final RouteSpec EXTERNAL_SIGNED = new RouteSpec("EXTERNAL", "SIGNED", 0, "CALLER", 0); + private static final RouteSpec EXTERNAL_ANON = new RouteSpec("EXTERNAL", "ANON", 0, "CALLER", 0); + + private static final AnonRateLimiter ALLOW_LIMITER = (ip, qps) -> AnonRateLimiter.Decision.ALLOW; + private static final AnonRateLimiter EXCEED_LIMITER = (ip, qps) -> AnonRateLimiter.Decision.EXCEEDED; + + private SignVerifyFilter filter(boolean observe, boolean sourceTrusted, AnonRateLimiter limiter) { + SignVerifyFilter f = new SignVerifyFilter(); + GatewayKeyService keyService = mock(GatewayKeyService.class); + when(keyService.getSecret("app1")).thenReturn(SECRET); + GatewayProperties props = new GatewayProperties(); + props.setObserve(observe); + props.setTrustedProxies(sourceTrusted ? Arrays.asList("0.0.0.0/0") : new ArrayList<>()); + f.setKeyService(keyService); + f.setProperties(props); + f.setAnonRateLimiter(limiter); + return f; + } + + /** 已过 RoutingFilter 的请求:routeSpec + 剥段 path;remoteAddr 默认 127.0.0.1(落在 0.0.0.0/0 内) */ + private MockHttpServletRequest request(RouteSpec spec, String xEntry) { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/message/list"); + req.setAttribute(SanitizeFilter.ATTR_STRIPPED_PATH, "/oapi/message/list"); + req.setAttribute(RoutingFilter.ATTR_ROUTE_SPEC, spec); + if (xEntry != null) { + req.addHeader(EntryDetector.HEADER_ENTRY, xEntry); + } + return req; + } + + /** 带合法三件套、且来自可信来源(标记为外部入口) */ + private MockHttpServletRequest signedExternalRequest(RouteSpec spec) { + MockHttpServletRequest req = request(spec, EntryDetector.ENTRY_EXTERNAL); + String ts = String.valueOf(System.currentTimeMillis()); + req.addHeader("X-App-Key", "app1"); + req.addHeader("X-Timestamp", ts); + req.addHeader("X-Signature", SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET)); + return req; + } + + private MockHttpServletResponse run(SignVerifyFilter f, MockHttpServletRequest req, MockFilterChain chain) + throws Exception { + MockHttpServletResponse res = new MockHttpServletResponse(); + f.doFilter(req, res, chain); + return res; + } + + @Test + void A1_内部入口_ANON路由仍走验签不因策略放行() throws Exception { + MockFilterChain chain = new MockFilterChain(); + // 无 X-Entry ⇒ 内部入口;即便路由是 EXTERNAL×ANON、限流就绪也不该免签放行 + MockHttpServletRequest req = request(EXTERNAL_ANON, null); + MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).as("内部入口不看 authMode:无签名即裸 401").isEqualTo(401); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL); + assertThat(chain.getRequest()).as("链条中断").isNull(); + } + + @Test + void A1_内部入口_坏签名observe为true仍只记不拦() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest req = request(EXTERNAL_ANON, null); + MockHttpServletResponse res = run(filter(true, true, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).as("内部入口的 observe 语义不变").isEqualTo(200); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL_OBSERVED); + } + + @Test + void A2_外部入口打INTERNAL路由_裸403且不受observe影响() throws Exception { + MockFilterChain chain = new MockFilterChain(); + // observe=true 也不放行:策略层拒绝与 observe 正交(同 400/404) + MockHttpServletRequest req = request(INTERNAL_SIGNED, EntryDetector.ENTRY_EXTERNAL); + MockHttpServletResponse res = run(filter(true, true, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).isEqualTo(403); + assertThat(res.getContentAsByteArray()).as("裸码无信封").isEmpty(); + assertThat(chain.getRequest()).isNull(); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL); + } + + @Test + void 外部入口_未经路由判定_同样裸403() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest req = request(null, EntryDetector.ENTRY_EXTERNAL); + MockHttpServletResponse res = run(filter(true, true, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).as("spec 缺失按最严:外部入口永不默认放行").isEqualTo(403); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void A3_外部入口EXTERNAL_SIGNED_无签名裸401() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest req = request(EXTERNAL_SIGNED, EntryDetector.ENTRY_EXTERNAL); + MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).isEqualTo(401); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void 外部入口EXTERNAL_SIGNED_合法签名放行() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest req = signedExternalRequest(EXTERNAL_SIGNED); + MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).isEqualTo(200); + assertThat(chain.getRequest()).isNotNull(); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).isEqualTo("app1"); + } + + @Test + void A4_外部入口EXTERNAL_ANON_限流内免签放行且不注入身份头() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL); + MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).isEqualTo(200); + assertThat(chain.getRequest()).isNotNull(); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).as("台账 caller=anonymous 的依据") + .isEqualTo(SignVerifyFilter.VERDICT_ANON); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("匿名就是匿名:不注入 appKey").isNull(); + } + + @Test + void A5_外部入口EXTERNAL_ANON_超限裸429() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL); + MockHttpServletResponse res = run(filter(false, true, EXCEED_LIMITER), req, chain); + + assertThat(res.getStatus()).isEqualTo(429); + assertThat(chain.getRequest()).isNull(); + assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL); + } + + @Test + void A5_外部入口EXTERNAL_ANON_限流未就绪failClosd裸403() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL); + // 限流 bean 缺失(本期 UnavailableAnonRateLimiter 的等价形态)⇒ 不放行 + MockHttpServletResponse res = run(filter(false, true, null), req, chain); + + assertThat(res.getStatus()).as("未接限流的匿名口子必须拒绝,不是放行").isEqualTo(403); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void A6_来源不可信时伪造XEntry无效_按内部入口处理() throws Exception { + MockFilterChain chain = new MockFilterChain(); + // trusted-proxies 为空(来源不可信)——客户端自带 X-Entry: external 也不能自封外部入口 + MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL); + MockHttpServletResponse res = run(filter(false, false, ALLOW_LIMITER), req, chain); + + assertThat(res.getStatus()).as("伪造标记无效:走内部口径验签 → 401").isEqualTo(401); + assertThat(chain.getRequest()).isNull(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/forward/StreamingForwarderTest.java b/src/test/java/com/yunhai/platform/gateway/forward/StreamingForwarderTest.java index 9e26e5c..432b62b 100644 --- a/src/test/java/com/yunhai/platform/gateway/forward/StreamingForwarderTest.java +++ b/src/test/java/com/yunhai/platform/gateway/forward/StreamingForwarderTest.java @@ -25,9 +25,9 @@ import java.util.concurrent.Executors; import static org.assertj.core.api.Assertions.assertThat; /** - * StreamingForwarder 五用例(T1.3 TDD,假后端 = JDK 自带 com.sun.net.httpserver.HttpServer, + * StreamingForwarder 用例(T1.3 TDD,假后端 = JDK 自带 com.sun.net.httpserver.HttpServer, * 零新增测试依赖):GET 转发 forwardUrl 逐字符 + 响应透传;POST body 流式透传; - * 客户端伪造 X-Caller-App-Key 不出现在转发请求;签名头四件原样到达; + * 客户端伪造 X-Caller-App-Key / X-Entry 不出现在转发请求;签名头四件原样到达; * 超时裸 504 / 连接拒绝裸 502(另附 body 超限裸 413——实现要点契约)。 */ class StreamingForwarderTest { @@ -169,6 +169,19 @@ class StreamingForwarderTest { assertThat(lastHeaders).as("身份头唯一合法来源是网关(T1.4)").doesNotContainKey("x-caller-app-key"); } + @Test + void 入口标记头XEntry不透传提供方() throws Exception { + MockHttpServletRequest req = request("GET", "/userbase/oapi/x"); + req.addHeader("X-Entry", "external"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024); + + assertThat(res.getStatus()).isEqualTo(201); + assertThat(lastHeaders).as("T4.3:入口标记是网关与 Caddy 之间的约定,提供方不该看到(伪造值也到不了)") + .doesNotContainKey("x-entry"); + } + @Test void 签名头四件原样到达假后端() throws Exception { MockHttpServletRequest req = request("GET", "/userbase/oapi/x"); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 9a316e7..a25ea09 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -21,3 +21,8 @@ management: # 测试上下文没有 redis(默认 localhost:6379 连不上会让聚合探针 503); # E04a 用例要钉的是「探针不经 oapi 链」= 平台探活口径 HTTP 200 enabled: false + +yh: + gateway: + # 变更广播需 Redis:测试上下文关掉(路由判定读本地 Caffeine,与广播无关) + redis-broadcast-enabled: false