判定接线(SignVerifyFilter 拆三段,内部入口逻辑逐字节不变):
- 内部入口(容器网直连,无标记)一律验签,不看 visibility/authMode
- 外部入口(来源可信 + X-Entry: external)→ INTERNAL 裸 403;EXTERNAL×SIGNED 验签;
EXTERNAL×ANON 限流内免签放行(verdict=ANON / caller=anonymous / 不注入身份头),
超限 429、限流未就绪 403(fail-closed)
- 外部入口的 403/429 属策略层拒绝,与 observe 正交
新增:
- auth/EntryDetector:可信来源 CIDR + X-Entry 双条件判定,仅认 IPv4,非法一律不可信
- auth/AnonRateLimiter:ALLOW/EXCEEDED/UNAVAILABLE 契约
- auth/UnavailableAnonRateLimiter:T3.1 前的占位实现,恒 fail-closed
- route/RouteChangeBroadcaster:频道 gw:route:version 变更广播(判定仍读本地 Caffeine,
Redis 只做变更通知,Redis 异常回落本地快照,不存在“挂了就放行”)
其他:
- GatewayProperties 新增 trusted-proxies / anon-per-ip-qps
- StreamingForwarder 把 x-entry 加入剥除集(伪造值也到不了提供方)
- InternalAdminController 白名单 {SIGNED,ANON}、删 page()、写后广播;RouteRefreshController 广播
- 硬删 static/admin.html(治理面已前端化,重载注册表由 front 头部按钮承接)
- 测试:EntryDetectorTest、SignVerifyFilterEntryTest(入口×策略矩阵)等,101/101 全绿
This commit is contained in:
@@ -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 链)。
|
||||
* <p>
|
||||
* <b>前端</b>:治理面 UI 独立在 {@code open-gateway/front}(Vue3),本类不再下发 HTML
|
||||
* (v1 内置 {@code static/admin.html} 已退役)。
|
||||
* <p>
|
||||
* <b>响应契约</b>:成功响应由 {@code yh-api-std} 的 {@code ApiResponseAdvice} 统一包成
|
||||
* {@code {code:0, msg:"", data:...}}(与 notice/userbase 一致);非法入参走 {@link #badRequest}
|
||||
@@ -49,7 +49,8 @@ import java.util.Set;
|
||||
* <b>收网前提示</b>:本前缀当前宿主端口可达(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<String> VISIBILITY = new HashSet<>(Arrays.asList("INTERNAL", "EXTERNAL"));
|
||||
private static final Set<String> AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANONYMOUS"));
|
||||
private static final Set<String> AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANON"));
|
||||
private static final Set<String> 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<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("loaded", routeRegistry.reload());
|
||||
broadcast();
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -191,9 +189,17 @@ public class InternalAdminController {
|
||||
targetRepo.save(target);
|
||||
Map<String, Object> 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 可空=不过滤。
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.platform.gateway.auth;
|
||||
|
||||
/**
|
||||
* ANON 路由的 per-IP 限流判定钩子(T3.5 约束:「ANON 的风险面就是匿名,必须限」)。
|
||||
* <p>
|
||||
* 契约:
|
||||
* <ul>
|
||||
* <li>{@link Decision#ALLOW} —— 限流内,免签放行(台账 verdict=ANON、caller=anonymous);</li>
|
||||
* <li>{@link Decision#EXCEEDED} —— 超限,裸 <b>429</b>;</li>
|
||||
* <li>{@link Decision#UNAVAILABLE} —— 限流能力未就绪/不可用,<b>fail-closed 拒绝</b>(裸 403)。</li>
|
||||
* </ul>
|
||||
* 本期(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);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.yunhai.platform.gateway.auth;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 入口来源判定(T4.3):判定一次请求是否走「外部入口」。
|
||||
* <p>
|
||||
* 外部入口 = <b>来源可信</b>({@code remoteAddr} 落在 {@code yh.gateway.trusted-proxies}
|
||||
* 的某个 CIDR 内)<b>且</b> 带 {@code X-Entry: external}(大小写与首尾空白不敏感)。
|
||||
* 两条件缺一即按<b>内部入口</b>处理——来源不可信时客户端伪造的 {@code X-Entry} 天然无效
|
||||
* (A6),该头本身也不会转发给提供方({@code StreamingForwarder} 剥除),
|
||||
* 故提供方永远看不到标记头。
|
||||
* <p>
|
||||
* 信任列表为空(默认)⇒ 不存在外部入口,全站按内部口径(一律验签)。
|
||||
* 仅识别 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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} 路由不会被免签放行。
|
||||
* <p>
|
||||
* 为什么不是"先放行、后补限流":匿名入口一旦无限制开放,暴露面在限流落地前就是全开的,
|
||||
* 且期间无法回收(第三方可能已经接入)。宁可先不给,也不留无限流的口子。
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
* <p>
|
||||
* <b>先分入口,再分策略</b>(落地总纲 §0.1 判定矩阵):
|
||||
* <ul>
|
||||
* <li><b>内部入口</b>(容器网直连,无标记)—— 一律验签,<b>不看</b> {@code visibility}/{@code authMode}。
|
||||
* 验签成本≈0,而 appKey 是限流/缓存隔离/台账归因的唯一键;</li>
|
||||
* <li><b>外部入口</b>({@link EntryDetector}:来源可信 且 {@code X-Entry: external})——
|
||||
* {@code INTERNAL} 路由裸 <b>403</b>;{@code EXTERNAL×SIGNED} 验签;{@code EXTERNAL×ANON}
|
||||
* 限流内免签放行({@link #VERDICT_ANON},台账 caller=anonymous),限流未就绪则 fail-closed 拒绝。</li>
|
||||
* </ul>
|
||||
* 外部入口的 403/429 属策略层拒绝,与 {@code observe} <b>正交</b>(同 400/404:策略与协议错误永不放行)。
|
||||
* <p>
|
||||
* 验签口径(T1.2,共享向量):canonical 与 starter
|
||||
* {@code AppAuthFilter.buildSignedTarget + SignCanonical} 逐字节一致——
|
||||
* {@code METHOD\n剥掉 target 首段后的 path[?解码排序query]\ntimestamp},HmacSHA256-Base64(F1),
|
||||
* 与客户端"剥 basePath 后签名"严格对齐;比对复用 starter 侧同一 {@link SignUtil}(不重写算法),
|
||||
* 时间窗 ±5min(F2,不得更宽)。
|
||||
* <p>
|
||||
* 头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = fail-closed)/ 验签失败 /
|
||||
* 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)放行(R4),
|
||||
* observe=false 裸 <b>401</b>(F5 契约)。判定结果经 request attribute 传递
|
||||
* 时间窗 ±5min(F2,不得更宽)。头缺失 / appKey 未知或禁用({@link GatewayKeyService} null =
|
||||
* fail-closed)/ 验签失败 / 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)
|
||||
* 放行(R4),observe=false 裸 <b>401</b>(F5 契约)。判定结果经 request attribute 传递
|
||||
* ({@link #ATTR_VERDICT} / {@link #ATTR_AUTH_APPKEY}),供 T1.4 身份注入与 T1.5 台账消费。
|
||||
* <p>
|
||||
* 链序第 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);
|
||||
|
||||
@@ -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;
|
||||
* <li>读超时默认 30s({@link #DEFAULT_READ_TIMEOUT_MS};route.timeout_ms 覆盖,本期注册表不配 = 统一默认);</li>
|
||||
* <li>请求头:保留 {@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
|
||||
* 由客户端库重算);</li>
|
||||
* <li>body 上限 route.request_body_limit_mb(默认 10MB),超限<b>裸 413</b>;</li>
|
||||
* <li>响应 status/headers/body 原样透传(不经 EnvelopeWriter);连接失败 → <b>裸 502</b>,
|
||||
@@ -70,7 +72,10 @@ public class StreamingForwarder {
|
||||
Set<String> 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<String> r = new HashSet<>();
|
||||
Collections.addAll(r, "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
|
||||
@@ -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;
|
||||
* <ul>
|
||||
* <li>routes:target → base-url(容器网内服务直连 URL,如 http://userbase-service:8080;
|
||||
* target = F11 七个生产 ctx);</li>
|
||||
* <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4)。</li>
|
||||
* <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4);</li>
|
||||
* <li>trustedProxies:外部入口判定的可信来源(T4.3,CIDR 列表)——仅这些来源的
|
||||
* {@code X-Entry: external} 生效;留空 = 不存在外部入口;</li>
|
||||
* <li>anonPerIpQps:ANON 路由未配 {@code rate_limit_qps} 时的 per-IP 缺省限额(T3.1 消费)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String> trustedProxies = new ArrayList<>();
|
||||
|
||||
/** ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(>0) */
|
||||
private int anonPerIpQps = 10;
|
||||
|
||||
public Map<String, String> getRoutes() {
|
||||
return routes;
|
||||
}
|
||||
@@ -39,4 +54,20 @@ public class GatewayProperties {
|
||||
public void setObserve(boolean observe) {
|
||||
this.observe = observe;
|
||||
}
|
||||
|
||||
public List<String> getTrustedProxies() {
|
||||
return trustedProxies;
|
||||
}
|
||||
|
||||
public void setTrustedProxies(List<String> trustedProxies) {
|
||||
this.trustedProxies = trustedProxies == null ? new ArrayList<>() : trustedProxies;
|
||||
}
|
||||
|
||||
public int getAnonPerIpQps() {
|
||||
return anonPerIpQps;
|
||||
}
|
||||
|
||||
public void setAnonPerIpQps(int anonPerIpQps) {
|
||||
this.anonPerIpQps = anonPerIpQps;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()},免重启、免逐实例点「重载注册表」。
|
||||
* <p>
|
||||
* <b>Redis 在这里的角色是「变更通知」,不是判定主存</b>:路由判定始终读本地 Caffeine(O(1) 不查库),
|
||||
* 故 Redis 异常时各实例停留在本地快照(stale 但准确),绝不会出现「Redis 挂了就放行」——
|
||||
* 广播通道断了最多是"改动晚一点生效"。
|
||||
* <p>
|
||||
* 失败口径:发布失败只记 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) {
|
||||
// 关闭期异常无需处理
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import java.util.Map;
|
||||
/**
|
||||
* 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达,
|
||||
* 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。
|
||||
* <p>
|
||||
* 重载后向 {@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<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("loaded", routeRegistry.reload());
|
||||
body.put("targets", routeRegistry.reloadTargets());
|
||||
if (broadcaster != null) {
|
||||
broadcaster.publish();
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>网关治理</title>
|
||||
<style>
|
||||
:root { --line:#e2e8f0; --txt:#1a202c; --sub:#718096; --ok:#276749; --err:#c53030; }
|
||||
* { box-sizing:border-box; }
|
||||
body { font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; color:var(--txt);
|
||||
margin:0; background:#f7fafc; }
|
||||
header { background:#fff; border-bottom:1px solid var(--line); padding:14px 24px;
|
||||
display:flex; align-items:center; gap:16px; position:sticky; top:0; z-index:5; }
|
||||
header h1 { font-size:17px; margin:0; }
|
||||
header .sp { flex:1; }
|
||||
button { padding:5px 14px; border:1px solid var(--line); background:#fff; border-radius:6px;
|
||||
cursor:pointer; font-size:13px; }
|
||||
button:hover { background:#edf2f7; }
|
||||
button.primary { background:#2b6cb0; color:#fff; border-color:#2b6cb0; }
|
||||
main { max-width:1280px; margin:0 auto; padding:20px 24px 60px; }
|
||||
section { background:#fff; border:1px solid var(--line); border-radius:8px; margin-bottom:22px;
|
||||
overflow:hidden; }
|
||||
section > h2 { font-size:15px; margin:0; padding:12px 18px; border-bottom:1px solid var(--line);
|
||||
background:#fbfcfd; display:flex; align-items:center; gap:10px; }
|
||||
section > h2 .sp { flex:1; }
|
||||
.bar { padding:10px 18px; display:flex; gap:10px; align-items:center; border-bottom:1px solid var(--line);
|
||||
flex-wrap:wrap; }
|
||||
.bar label { color:var(--sub); font-size:13px; }
|
||||
.bar input, .bar select { padding:4px 8px; border:1px solid var(--line); border-radius:5px; font-size:13px; }
|
||||
table { border-collapse:collapse; width:100%; font-size:13px; }
|
||||
th, td { padding:7px 10px; border-bottom:1px solid var(--line); text-align:left;
|
||||
white-space:nowrap; max-width:340px; overflow:hidden; text-overflow:ellipsis; }
|
||||
th { color:var(--sub); font-weight:600; background:#fbfcfd; }
|
||||
td input, td select { padding:3px 6px; border:1px solid var(--line); border-radius:4px;
|
||||
font-size:12.5px; width:100%; }
|
||||
td .num { width:72px; }
|
||||
.tag { display:inline-block; padding:1px 8px; border-radius:10px; font-size:12px; }
|
||||
.tag.on { background:#c6f6d5; color:var(--ok); }
|
||||
.tag.off { background:#fed7d7; color:var(--err); }
|
||||
.st2 { color:var(--ok); } .st4xx { color:#b7791f; } .st5xx { color:var(--err); }
|
||||
.idle { color:var(--sub); padding:26px; text-align:center; }
|
||||
#toast { position:fixed; left:50%; bottom:34px; transform:translateX(-50%); background:#2d3748;
|
||||
color:#fff; padding:9px 20px; border-radius:8px; font-size:13px; opacity:0;
|
||||
transition:opacity .25s; pointer-events:none; max-width:70vw; }
|
||||
#toast.show { opacity:.96; }
|
||||
#toast.err { background:var(--err); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>网关治理</h1>
|
||||
<span class="sp"></span>
|
||||
<span id="stat" style="color:var(--sub);font-size:12.5px"></span>
|
||||
<button class="primary" onclick="reloadRegistry()">重载注册表</button>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
<section>
|
||||
<h2>路由治理旋钮 <span class="sp"></span>
|
||||
<span style="color:var(--sub);font-weight:400;font-size:12px">值域 visibility=INTERNAL/EXTERNAL · authMode=SIGNED/ANONYMOUS · cacheScope=CALLER/GLOBAL</span></h2>
|
||||
<div class="tableWrap"><table id="routeT">
|
||||
<thead><tr>
|
||||
<th>id</th><th>target</th><th>path</th><th>method</th>
|
||||
<th style="width:110px">visibility</th><th style="width:120px">authMode</th>
|
||||
<th style="width:80px">cacheTtl</th><th style="width:100px">cacheScope</th>
|
||||
<th style="width:80px">qps</th><th>启用</th><th>备注</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>目标地址</h2>
|
||||
<div class="tableWrap"><table id="tgtT">
|
||||
<thead><tr><th>id</th><th>target</th><th>base_url</th><th>启用</th><th>更新时间</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>访问台账</h2>
|
||||
<div class="bar">
|
||||
<label>条数</label><input id="qLimit" type="number" value="50" min="1" max="500" style="width:76px">
|
||||
<label>target</label><input id="qTarget" placeholder="如 notice" style="width:110px">
|
||||
<label>caller</label><input id="qCaller" placeholder="如 100001" style="width:110px">
|
||||
<button onclick="loadLogs()">查询</button>
|
||||
<span id="logTotal" style="color:var(--sub);font-size:12px"></span>
|
||||
</div>
|
||||
<div class="tableWrap"><table id="logT">
|
||||
<thead><tr><th>id</th><th>时间</th><th>caller</th><th>target</th><th>method</th>
|
||||
<th>path</th><th>status</th><th>backend</th><th>cost(ms)</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
var $ = function(id){ return document.getElementById(id); };
|
||||
|
||||
function toast(msg, isErr) {
|
||||
var t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'show' + (isErr ? ' err' : '');
|
||||
setTimeout(function(){ t.className = ''; }, isErr ? 4200 : 2200);
|
||||
}
|
||||
|
||||
/* 管理面响应 = 平台信封 {code,msg,data}(yh-api-std 统一包装);成功时把 data 交给调用方 */
|
||||
function api(method, url, body) {
|
||||
var opt = { method: method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opt.headers['Content-Type'] = 'application/json';
|
||||
opt.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch(url, opt).then(function(r) {
|
||||
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
||||
return r.json().then(function(j) {
|
||||
if (j.code !== 0) { throw new Error(j.msg || ('业务错误 code=' + j.code)); }
|
||||
return j.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s == null ? '' : String(s).replace(/[&<>"]/g, function(c) {
|
||||
return { '&':'&', '<':'<', '>':'>', '"':'"' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 路由表 ---------- */
|
||||
function loadRoutes() {
|
||||
return api('GET', 'routes?pageIndex=1&pageSize=200').then(function(pg) {
|
||||
var rows = (pg && pg.content) || [];
|
||||
var tb = $('routeT').tBodies[0];
|
||||
tb.innerHTML = rows.map(function(r) { return routeRow(r); }).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function routeRow(r) {
|
||||
return '<tr data-id="' + r.id + '">'
|
||||
+ '<td>' + r.id + '</td><td><b>' + esc(r.targetCode) + '</b></td>'
|
||||
+ '<td title="' + esc(r.pathPattern) + '">' + esc(r.pathPattern) + '</td>'
|
||||
+ '<td>' + esc(r.httpMethod) + '</td>'
|
||||
+ '<td><select data-k="visibility">'
|
||||
+ opt('INTERNAL', r.visibility) + opt('EXTERNAL', r.visibility) + '</select></td>'
|
||||
+ '<td><select data-k="authMode">'
|
||||
+ opt('SIGNED', r.authMode) + opt('ANONYMOUS', r.authMode) + '</select></td>'
|
||||
+ '<td><input class="num" type="number" min="0" data-k="cacheTtlSeconds" value="' + r.cacheTtlSeconds + '"></td>'
|
||||
+ '<td><select data-k="cacheScope">'
|
||||
+ opt('CALLER', r.cacheScope) + opt('GLOBAL', r.cacheScope) + '</select></td>'
|
||||
+ '<td><input class="num" type="number" min="0" data-k="rateLimitQps" value="' + (r.rateLimitQps || 0) + '"></td>'
|
||||
+ '<td><input type="checkbox" data-k="enabled"' + (r.enabled ? ' checked' : '') + '></td>'
|
||||
+ '<td><input data-k="remark" value="' + esc(r.remark || '') + '" placeholder="-"></td>'
|
||||
+ '<td><button onclick="saveRoute(this)">保存</button></td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
|
||||
function opt(v, cur) {
|
||||
return '<option value="' + v + '"' + (v === cur ? ' selected' : '') + '>' + v + '</option>';
|
||||
}
|
||||
|
||||
function saveRoute(btn) {
|
||||
var tr = btn.closest('tr');
|
||||
var body = {};
|
||||
tr.querySelectorAll('[data-k]').forEach(function(el) {
|
||||
var k = el.getAttribute('data-k');
|
||||
if (el.type === 'checkbox') { body[k] = el.checked; }
|
||||
else if (el.type === 'number') { body[k] = parseInt(el.value || '0', 10); }
|
||||
else { body[k] = el.value; }
|
||||
});
|
||||
api('PUT', 'routes/' + tr.dataset.id, body)
|
||||
.then(function(d) { toast('已保存,注册表加载 ' + d.loaded + ' 条路由'); })
|
||||
.catch(function(e) { toast('保存失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
/* ---------- 目标表 ---------- */
|
||||
function loadTargets() {
|
||||
return api('GET', 'targets?pageIndex=1&pageSize=200').then(function(pg) {
|
||||
var rows = (pg && pg.content) || [];
|
||||
var tb = $('tgtT').tBodies[0];
|
||||
tb.innerHTML = rows.map(function(t) {
|
||||
return '<tr data-id="' + t.id + '">'
|
||||
+ '<td>' + t.id + '</td><td><b>' + esc(t.targetCode) + '</b></td>'
|
||||
+ '<td><input data-k="baseUrl" value="' + esc(t.baseUrl) + '" style="width:300px"></td>'
|
||||
+ '<td><input type="checkbox" data-k="enabled"' + (t.enabled ? ' checked' : '') + '></td>'
|
||||
+ '<td>' + esc((t.gmtModified || '').replace('T', ' ').slice(0, 19)) + '</td>'
|
||||
+ '<td><button onclick="saveTarget(this)">保存</button></td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function saveTarget(btn) {
|
||||
var tr = btn.closest('tr');
|
||||
var body = {};
|
||||
tr.querySelectorAll('[data-k]').forEach(function(el) {
|
||||
var k = el.getAttribute('data-k');
|
||||
body[k] = el.type === 'checkbox' ? el.checked : el.value;
|
||||
});
|
||||
api('PUT', 'targets/' + tr.dataset.id, body)
|
||||
.then(function(d) { toast('已保存,目标表加载 ' + d.targets + ' 条'); })
|
||||
.catch(function(e) { toast('保存失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
/* ---------- 台账 ---------- */
|
||||
function loadLogs() {
|
||||
var size = parseInt($('qLimit').value || '50', 10) || 50;
|
||||
var q = 'logs?pageIndex=1&pageSize=' + size;
|
||||
if ($('qTarget').value.trim()) q += '&targetCode=' + encodeURIComponent($('qTarget').value.trim());
|
||||
if ($('qCaller').value.trim()) q += '&callerAppKey=' + encodeURIComponent($('qCaller').value.trim());
|
||||
return api('GET', q).then(function(pg) {
|
||||
$('logTotal').textContent = '共 ' + pg.total + ' 条';
|
||||
var rows = (pg && pg.content) || [];
|
||||
var tb = $('logT').tBodies[0];
|
||||
if (!rows.length) {
|
||||
tb.innerHTML = '<tr><td colspan="9" class="idle">无匹配记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = rows.map(function(l) {
|
||||
var st = l.status;
|
||||
var cls = st >= 500 ? 'st5xx' : (st >= 400 ? 'st4xx' : 'st2');
|
||||
return '<tr>'
|
||||
+ '<td>' + l.id + '</td>'
|
||||
+ '<td>' + esc((l.ts || '').replace('T', ' ').slice(0, 23)) + '</td>'
|
||||
+ '<td>' + esc(l.callerAppKey == null ? '-' : l.callerAppKey) + '</td>'
|
||||
+ '<td>' + esc(l.targetCode) + '</td><td>' + esc(l.method) + '</td>'
|
||||
+ '<td title="' + esc(l.path) + '">' + esc(l.path) + '</td>'
|
||||
+ '<td class="' + cls + '">' + st + '</td>'
|
||||
+ '<td>' + (l.backendCode == null ? '-' : l.backendCode) + '</td>'
|
||||
+ '<td>' + l.costMs + '</td></tr>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 顶部 ---------- */
|
||||
function reloadRegistry() {
|
||||
api('POST', '../routes/refresh')
|
||||
.then(function(d) { toast('注册表已重载:路由 ' + d.loaded + ' 条 / 目标 ' + d.targets + ' 条'); })
|
||||
.catch(function(e) { toast('重载失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
Promise.all([loadRoutes(), loadTargets(), loadLogs()])
|
||||
.then(function() { $('stat').textContent = '更新于 ' + new Date().toLocaleTimeString(); })
|
||||
.catch(function(e) { toast('加载失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 仓储/注册表。
|
||||
* <p>
|
||||
* <b>契约说明(B3/B4 后)</b>:查询接口返回 {@code BasePageResult}({@code {total, content, ...}}),
|
||||
* 非法入参返回 <b>HTTP 200</b> + {@code {code:2, msg}}(平台规范)。standalone 模式不装配
|
||||
* {@code yh-api-std} 的 {@code ApiResponseAdvice},故成功响应在本测试中保持裸结构;
|
||||
* 线上由 advice 统一再包一层 {@code {code,msg,data}}(分页体落在 {@code data})。
|
||||
* <p>
|
||||
* 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("<html");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void routes_listSorted() throws Exception {
|
||||
mvc.perform(get("/api/internal/admin/routes"))
|
||||
@@ -99,7 +92,7 @@ class InternalAdminControllerTest {
|
||||
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
|
||||
mvc.perform(put("/api/internal/admin/routes/1")
|
||||
.contentType("application/json")
|
||||
.content("{\"authMode\":\"ANONYMOUS\",\"cacheTtlSeconds\":30,\"enabled\":true}"))
|
||||
.content("{\"authMode\":\"ANON\",\"cacheTtlSeconds\":30,\"enabled\":true}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.loaded").value(22));
|
||||
verify(routeRepo).save(any(OpenApiRoute.class));
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.yunhai.platform.gateway.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 入口判定用例(T4.3):外部入口 = 来源可信(CIDR 命中)<b>且</b> 带 X-Entry: external。
|
||||
* 覆盖 CIDR 边界、映射地址形态、非法/空缺配置的 fail-closed 行为——伪造 X-Entry(来源不可信)
|
||||
* 必须无效,这是"客户端不能自封为外部入口"的唯一保证(A6)。
|
||||
*/
|
||||
class EntryDetectorTest {
|
||||
|
||||
private static final List<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
* <ul>
|
||||
* <li>A1 内部入口(无标记)—— 任意路由一律验签,不看 {@code visibility}/{@code authMode};</li>
|
||||
* <li>A2 外部入口打 {@code INTERNAL} 路由 —— 裸 403(且不因 observe=true 而放行);</li>
|
||||
* <li>A3 外部入口打 {@code EXTERNAL×SIGNED} 无签名 —— 裸 401;</li>
|
||||
* <li>A4 外部入口打 {@code EXTERNAL×ANON} 限流内 —— 免签放行,verdict=ANON、不注入身份头;</li>
|
||||
* <li>A5 同上超限 —— 裸 429;限流未就绪 —— 裸 403(fail-closed,D4);</li>
|
||||
* <li>A6 来源不可信时伪造 {@code X-Entry: external} —— 无效,仍按内部入口处理。</li>
|
||||
* </ul>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -21,3 +21,8 @@ management:
|
||||
# 测试上下文没有 redis(默认 localhost:6379 连不上会让聚合探针 503);
|
||||
# E04a 用例要钉的是「探针不经 oapi 链」= 平台探活口径 HTTP 200
|
||||
enabled: false
|
||||
|
||||
yh:
|
||||
gateway:
|
||||
# 变更广播需 Redis:测试上下文关掉(路由判定读本地 Caffeine,与广播无关)
|
||||
redis-broadcast-enabled: false
|
||||
|
||||
Reference in New Issue
Block a user