diff --git a/src/main/java/com/inspect/nvr/hik/service/HikNvrLivePublicBaseUrlResolver.java b/src/main/java/com/inspect/nvr/hik/service/HikNvrLivePublicBaseUrlResolver.java new file mode 100644 index 00000000..a5a74e99 --- /dev/null +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrLivePublicBaseUrlResolver.java @@ -0,0 +1,222 @@ +package com.inspect.nvr.hik.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; +import java.util.Locale; + +/** + * 按浏览器请求的目标地址生成实时预览对外播放地址。 + * + *

现场同一套服务会同时暴露内网与外网两个 IP(例如 192.168.20.210 与 192.168.134.10), + * 而 flvUrl/hlsUrl 只能返回固定地址时,从另一侧访问的浏览器会拿到不可达的地址。 + * 这里用请求中的 X-Forwarded-Host/Host 主机名替换 ZLM_HTTP_BASE_URL 的主机部分, + * 协议和端口仍沿用 .env 配置:ZLMediaKit 的 HTTP 端口与网关端口不一致时地址依然正确, + * 请求不携带可用主机名时回退到 ZLM_HTTP_BASE_URL。

+ */ +@Slf4j +@Component +public class HikNvrLivePublicBaseUrlResolver { + + /** 回退使用的固定基础地址,与实时服务读取的配置项保持一致。 */ + @Value("${hik.playback.hls.http-base-url:http://127.0.0.1:18081}") + private String configuredBaseUrl = "http://127.0.0.1:18081"; + + /** 规范化后的回退基础地址。 */ + private String fallbackBaseUrl; + + /** 校验并规范化回退基础地址。 */ + @PostConstruct + public void initialize() { + // 回退地址缺省时保留空串,交由实时服务的配置校验统一报错。 + this.fallbackBaseUrl = trimTrailingSlash( + configuredBaseUrl == null ? "" : configuredBaseUrl.trim()); + } + + /** + * 解析本次请求应返回给浏览器的 ZLMediaKit 基础地址。 + * + * @param request 当前 HTTP 请求;后台线程或内部调用传入 null 时使用固定配置 + * @return 带协议和端口的基础地址,例如 http://192.168.134.10:18081 + */ + public String resolve(HttpServletRequest request) { + // 空闲回收、状态补偿等后台调用没有请求上下文,直接沿用固定配置。 + if (request == null) { + return fallbackBaseUrl; + } + String requestHost = firstUsableHost(request); + // 代理链可能把原始地址放在 X-Forwarded-Host,其次才是 Host。 + String resolved = replaceAuthorityHost(fallbackBaseUrl, requestHost); + if (resolved == null) { + // 取不到可信主机名时保持原有固定地址行为,避免生成畸形播放地址。 + if (!requestHost.isEmpty()) { + log.debug("[live] 请求地址无法用于生成播放地址 requestHost={} fallback={}", + requestHost, fallbackBaseUrl); + } + return fallbackBaseUrl; + } + // 回环地址说明代理没有透传原始 Host,远程浏览器无法访问该地址,此时优先保留可配置的固定地址。 + if (isLoopback(requestHost) && !isLoopback(hostOf(fallbackBaseUrl))) { + log.warn("[live] 请求Host为回环地址,已回退到配置的播放地址 requestHost={} fallback={}", + requestHost, fallbackBaseUrl); + return fallbackBaseUrl; + } + return resolved; + } + + /** 依次读取 X-Forwarded-Host 和 Host,返回第一个可用的主机名。 */ + private String firstUsableHost(HttpServletRequest request) { + String forwardedHost = normalizeHost(request.getHeader("X-Forwarded-Host")); + if (!forwardedHost.isEmpty()) { + return forwardedHost; + } + return normalizeHost(request.getHeader("Host")); + } + + /** + * 从请求头值中提取纯主机名:忽略代理链后续节点,去掉端口部分。 + * + * @param rawHost 请求头原始值,可能形如 "192.168.20.210:18891, proxy1" + * @return 合法主机名;无法识别时返回空串 + */ + private String normalizeHost(String rawHost) { + if (rawHost == null) { + return ""; + } + String host = rawHost.trim(); + // X-Forwarded-Host 可能是逗号分隔的代理链,只取最前面的原始客户端地址。 + int comma = host.indexOf(','); + if (comma >= 0) { + host = host.substring(0, comma).trim(); + } + if (host.isEmpty()) { + return ""; + } + // IPv6 字面量形如 [fe80::1]:18891,保留方括号部分即可。 + if (host.startsWith("[")) { + int end = host.indexOf(']'); + if (end < 0 || !host.substring(0, end + 1).matches("\\[[0-9A-Fa-f:.]+\\]")) { + return ""; + } + return host.substring(0, end + 1); + } + // 只保留端口前的主机名;ZLM 端口固定取自配置,不接受请求里的端口。 + int colon = host.indexOf(':'); + if (colon >= 0) { + host = host.substring(0, colon).trim(); + } + // 仅接受常规主机名字符,防止把路径、空白或头注入内容拼进播放地址。 + if (!host.matches("[A-Za-z0-9._-]+")) { + return ""; + } + return host; + } + + /** + * 用请求主机名替换基础地址中的主机部分,保留协议、端口和路径前缀。 + * + * @param baseUrl 配置的基础地址,例如 http://192.168.20.210:18891 + * @param host 请求主机名,例如 192.168.134.10 + * @return 替换后的地址;基础地址不可解析时返回 null + */ + private String replaceAuthorityHost(String baseUrl, String host) { + if (baseUrl == null || baseUrl.isEmpty() || host.isEmpty()) { + return null; + } + int schemeEnd = baseUrl.indexOf("://"); + // 缺少协议或无主机部分的配置无法安全替换,保持原值由调用方回退。 + if (schemeEnd <= 0) { + return null; + } + String scheme = baseUrl.substring(0, schemeEnd + 3); + String remainder = baseUrl.substring(schemeEnd + 3); + int pathStart = remainder.indexOf('/'); + String authority = pathStart < 0 ? remainder : remainder.substring(0, pathStart); + String path = pathStart < 0 ? "" : remainder.substring(pathStart); + // 基础地址不应包含用户信息,出现时放弃替换,避免拼出畸形地址。 + if (authority.isEmpty() || authority.indexOf('@') >= 0) { + return null; + } + String port = configuredPort(authority); + // 端口非法时不做替换,避免把畸形地址返回给浏览器。 + if (port == null) { + return null; + } + // ZLMediaKit 端口固定沿用配置,只把主机名换成浏览器实际访问的那一个。 + return scheme + host + port + path; + } + + /** + * 提取基础地址中的端口部分。 + * + * @param authority 基础地址的主机部分,例如 192.168.20.210:18891 或 [fe80::1]:18891 + * @return 形如 ":18891" 的端口;未配置端口时返回空串;端口非法时返回 null + */ + private String configuredPort(String authority) { + // IPv6 字面量的端口写在方括号之后。 + if (authority.startsWith("[")) { + int end = authority.indexOf(']'); + if (end < 0) { + return null; + } + if (end + 1 >= authority.length()) { + return ""; + } + if (authority.charAt(end + 1) != ':') { + return null; + } + String port = authority.substring(end + 1); + return port.matches(":\\d{1,5}") ? port : null; + } + int colon = authority.indexOf(':'); + if (colon < 0) { + return ""; + } + String port = authority.substring(colon); + return port.matches(":\\d{1,5}") ? port : null; + } + + /** 读取地址中的主机名部分,用于回环判断。 */ + private String hostOf(String baseUrl) { + if (baseUrl == null || baseUrl.isEmpty()) { + return ""; + } + int schemeEnd = baseUrl.indexOf("://"); + if (schemeEnd <= 0) { + return ""; + } + String remainder = baseUrl.substring(schemeEnd + 3); + int pathStart = remainder.indexOf('/'); + String authority = pathStart < 0 ? remainder : remainder.substring(0, pathStart); + int at = authority.lastIndexOf('@'); + if (at >= 0) { + authority = authority.substring(at + 1); + } + if (authority.startsWith("[")) { + int end = authority.indexOf(']'); + return end < 0 ? "" : authority.substring(0, end + 1).toLowerCase(Locale.ROOT); + } + int colon = authority.indexOf(':'); + return (colon < 0 ? authority : authority.substring(0, colon)).toLowerCase(Locale.ROOT); + } + + /** 判断主机名是否为回环地址。 */ + private boolean isLoopback(String host) { + return "127.0.0.1".equals(host) + || "localhost".equals(host) + || "::1".equals(host) + || "[::1]".equals(host); + } + + /** 去除基础地址末尾多余斜杠。 */ + private String trimTrailingSlash(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') { + end--; + } + return value.substring(0, end); + } +}