前置:一种Android设备连接手机H5展示实时画面的方案
实时画面展示方案在本地联调测试时,运行良好,但发布预发环境后,实时画面无法展示,浏览器报错。
Mixed Content: The page at '*****' was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://*****'. This request has been blocked; this endpoint must be available over WSS.
(anonymous)
Uncaught DOMException: Failed to construct 'WebSocket': An insecure WebSocket connection may not be initiated from a page loaded over HTTPS.
经查,预发和线上环境都是 HTTPS 环境。HTTPS 是基于 SSL 证书来验证服务器的身份,并为浏览器和服务器之间的通信加密,所以在 HTTPS 站点调用某些非 SSL 验证的资源时浏览器可能会阻止。比如使用 ws://*** 调用 WebSocket 服务器或者引入类似 http://***.js 的 js 文件等都会报错。而本地测试是 HTTP 环境,因此没出现此问题。
基于安全考虑,线上只能是 HTTPS 环境,这就要求数据传输需要改成支持 SSL 协议的 wss 格式。
根据调研结果,想使用 wss,一是可以搭建 nginx 代理,将 wss 请求转发到不支持 SSL 的 WebSocket 服务;二是将 WebSocket 服务改造成支持 SSL 协议。
首先尝试在设备上搭建 nginx 的方式,理论上可以在 nginx官网 下载最新版本安装包后,通过 shell 命令安装。但在实际安装时被设备权限拦截,无法安装。
调研得知,Android 系统下可以通过 Termux 安装 nginx,Termux 是一个 Android 下一个高级的终端模拟器,开源且不需要 root,支持 apt 管理软件包,十分方便安装软件包,完美支持 Python、 PHP、 Ruby、 Nodejs、 MySQL 等。
参考Termux 高级终端安装使用配置教程,安装 Termux 和 nginx,成功。
修改 nginx 配置,根据网上的说法,nginx 配置使用 wss 有几点要求:
wss 不支持 ip + port 的连接方式,只能通过域名请求
后来实际测试时发现,这一条不是必须的,我猜想是由于我们搭建的是局域网内的本地 WebSocket 服务,不是公网环境,因此可以继续用 ip + port 的连接方式。
nginx 需要配置域名路径,访问路径为:wss://域名/wss/项目访问
# 建立 websocket连接
location /wss/ {
proxy_pass http://127.0.0.1:自己项目端口号/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-real-ip $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
nginx 需要配置 SSL 证书,由于是本地局域网环境,需要配置自签名证书
openssl req -x509 -nodes -days 36500 -newkey rsa:2048 -keyout cert.key -out cert.crt
server {
listen 端口;
server_name 域名地址;
#调整成自己的证书即可
ssl_certificate /usr/local/nginx/conf/ssl/xxxx.crt;
ssl_certificate_key /usr/local/nginx/conf/ssl/xxxx.key;
ssl_session_timeout 5m;
# 建立 websocket连接
location /wss/ {
proxy_pass http://127.0.0.1:自己项目端口号/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-real-ip $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
实际调试记录:
nginx 可以正常搭建联通,非 SSL 连接配置端口转发也能正常工作;
配置域名转发 IP 请求,连接不通。我理解是 nginx 本身不支持域名解析能力,需要 DNS 服务做支持。但我们是本地局域网环境,需要搭建本地 DNS 服务器,后续还要求用户在自己手机上配置本地 DNS 服务器。即使技术上可行,也增加了用户的操作;
配置 SSL 自签名证书后,nginx 连接不通。原因不详。
我后来思考分析,网上建议的配置 nginx 的方案,适用场景应该都是 HTTPS 页面和 WebSocket 服务都假设在公网同一服务环境下;而我们实际场景是 HTTPS 页面在公网服务,页面内调用的 WebSocket 服务在局域网内,应用场景不同,所以不论是域名代理还是 SSL 证书配置,都比网上方案实现起来要复杂。
即使 nginx 方案可行,也需要厂家配合,后续在设备上预装 nginx,并支持开机启动 nginx。
综合考虑,该方案被放弃。
WebSocket 支持 SSL 协议,需要在起服务时配置 SSL 证书。
由于我们是局域网环境,无法使用公网 CA 证书,需要申请自签名证书;
设备是 Android 系统,不支持 JKS 格式证书,只支持 BKS 格式证书;
keytool -genkeypair -alias socketKey -keyalg RSA -keysize 2048 -validity 36500 -keystore socket_keystore.bks -storetype BKS -provider org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath bcprov-jdk18on-173.jar
该命令将生成一个 2048 位的 RSA 密钥对,并将其存储在以 socket_keystore.bks 为名的密钥库文件中。其中 socketKey 和 36500 参数为别名和有效期。-provider 和 -providerpath 是 keytool 命令行工具的选项,用于指定密钥库的提供者和提供者路径。
实际执行命令时报错:
keytool 错误: java.security.KeyStoreException: BKS not found
这是由于在命令中,使用了 -provider 和 -providerpath 选项来指定 Bouncy Castle 作为密钥库的提供者,并将其路径设置为 bcprov-jdk18on-173.jar。
BKS 是 Bouncy Castle 提供的一种密钥库格式,如果想使用 Bouncy Castle 作为密钥库提供者,就需要在 Java 运行环境中安装 Bouncy Castle 提供者。可以在 Bouncy Castle 的官网下载并安装相应的提供者,下载地址为:Bouncy Castle。
安装完成后,可以在 Java 运行环境的安装目录下的 jre/lib/security 目录中找到 bouncycastle.jar 文件,该文件包含了 Bouncy Castle 的提供者实现。然后,需要将其添加到 Java 运行环境的安装目录下的 lib/security/java.security 文件中,添加以下一行代码:
security.provider.10=org.bouncycastle.jce.provider.BouncyCastleProvider
其中,10 为提供者的优先级,可以将其替换为需要的优先级。Mac 环境下配置bcprov-ext-jdk jar 文件_bcprov mac-CSDN博客
配置完成后,执行申请证书命令,根据提示填写证书密钥、使用者、组织信息即可。
另附:
keytool -genkeypair -alias socketkey -keyalg RSA -keysize 2048 -validity 36500 -keystore socket_keystore.jks -storepass 123456
接着在 WebSocket 服务添加支持 SSL 协议。
compile "org.java-websocket:Java-WebSocket:1.5.1"
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.DefaultSSLWebSocketServerFactory
import org.java_websocket.server.WebSocketServer
import java.security.KeyStore
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
/**
* @author zhoucj
* @since 2023/7/7
*/
class WifiSocketManager : ISocketManager {
private val TAG = "LocalSocketEngineImpl_WIFI"
private lateinit var wifiSocketServer: WifiSocketServer
override fun start() {
// websocket连接
Log.i(TAG, "Start ServerSocket...")
try {
url?.let {
wifiSocketServer = WifiSocketServer(8087)
wifiSocketServer.isReuseAddr = true
wifiSocketServer.isTcpNoDelay = true
wifiSocketServer.start()
}
} catch (e: Exception) {
Log.e(TAG, e)
}
}
inner class WifiSocketServer(port: Int) : WebSocketServer(InetSocketAddress(port)) {
var connect: WebSocket? = null
...
override fun onStart() {
Log.i(TAG, "onStart:")
try {
// 添加SSL支持
val sslContext = SSLContext.getInstance("TLS")
val keyStore = KeyStore.getInstance("BKS")
// 加载证书
val certInputStream = context.resources.openRawResource(R.raw.socket_keystore)
keyStore.load(certInputStream, "123456".toCharArray())
certInputStream.close()
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(keyStore)
val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, "123456".toCharArray())
sslContext.init(keyManagerFactory.keyManagers, trustManagerFactory.trustManagers, null)
setWebSocketFactory(DefaultSSLWebSocketServerFactory(sslContext))
} catch (e: Exception) {
Log.e(TAG, e)
}
}
}
}
wifiSocketServer.isReuseAddr = true 表示允许重用之前使用过的端口。通常情况下,当一个端口被占用时,操作系统会保持该端口处于 TIME_WAIT 状态一段时间,以确保网络中所有的数据包都被正确处理。而如果你在短时间内需要重新启动一个服务,操作系统可能会拒绝分配之前使用过的端口,因为该端口还处于 TIME_WAIT 状态。将 wifiSocketServer.isReuseAddr 属性设置为 true 可以允许你重用之前使用过的端口,从而避免该问题的出现。wifiSocketServer.isTcpNoDelay = true 表示启用 TCP_NODELAY,该选项可以禁用 Nagle 算法,从而使发送数据更及时。Nagle 算法是指缓存小型数据包并将它们组合成更大的数据包再发送,以减少网络上的流量。但在某些情况下,这可能会导致数据传输的延迟,因为发送方需要等待一定数量的小数据包才能将它们组合成更大的数据包。启用 TCP_NODELAY 可以禁用 Nagle 算法,从而使发送数据更及时。
启动服务,H5 连接,浏览器报错,但没有任务报错信息:
WebSocket connection to 'wss://xxxxxx' failed:
没有报错,无法在网上查到有效信息,尝试本地调试。而由于设备权限限制无法调试,因此写了个 WebSocket 服务 Demo 在手机上运行调试。跟踪代码执行,发现服务接到请求后处理时会报异常。
javax.net.ssl.SSLHandshakeException: Read error: ssl=0xb40000702e129658: Failure in SSL library, usually a protocol errorerror:10000416:SSL routines:OPENSSL_internal:SSLV3_ALERT_CERTIFICATE_UNKNOWN (external/boringssl/src/ssl/tls_record.cc:594 0xb40000704e12cfb8:0x00000001)
(这里吐槽下,WebSocket 库居然把异常内部消化而不抛出来,导致上层使用完全不知道为什么出错)
报错原因是自签名证书是由自己颁发或生成的证书,因此在浏览器或客户端中并没有得到认可和信任。当使用自签名证书时,可能会出现 SSL 握手过程中的 SSLV3_ALERT_CERTIFICATE_UNKNOWN 错误。这是因为客户端无法验证自签名证书的有效性和真实性,从而导致 SSL 握手失败。
要解决这个问题,可以将自签名证书添加到受信任的根证书机构中,这样客户端就会信任该证书。
而 wss 访问自签名证书地址时,浏览器仍然会阻止访问,而且没有报错提示。但是访问使用自签名证书的 HTTPS 地址时会有提示且允许用户信任。
手动访问和 wss://xxxxx 相同地址的 https://xxxxx,在证书信任提示时选择‘是’(各浏览器不一样,有的浏览器不会弹提示是否信任证书,但会在页面显示拦截信息询问是否要继续访问)。
然后再在同一浏览器环境打开实时画面展示页面,图像正常展示,成功。
由于多了一步需要用户信任证书的操作,需要产品修改交互逻辑。
进一步思考,Native 开发时,客户端可以通过代码强制信任所有证书,不用用户操作,H5是否也可以。
根据网上查询,有人提供可以在 wss 请求时增加配置。
var socket = new WebSocket('wss://example.com', null, {
rejectUnauthorized: false
});
但这里遇到两个问题。
一是网上的回答(包括 chatgpt)都省掉了中间的参数 null,导致执行报错。
DOMException: Failed to construct 'WebSocket': The subprotocol '[object Object]' is invalid.
这是因为H5 中,创建 WebSocket 对象时,可以传递三个参数:
url(必需):指定 WebSocket 服务器的 URL。
protocol(可选):指定要使用的协议或子协议。可以是一个字符串或字符串数组。
options(可选):指定 WebSocket 选项,例如子协议、证书信任和超时。这应该是一个包含选项属性的对象。
存在第三个参数时,不能省掉第二个参数。否则就会报错。
二是实际上 { rejectUnauthorized: false } 这个选项只在 Node.js 中可用,而在浏览器中不支持。在浏览器中这么写,反而会导致原本能连通的请求变得无法连通。
因此这个配置方案不可行。
这里 chatgpt 还一本正经地提供了另一个错误回答。将证书文件转换为一个 Base64 编码的字符串。将 Base64 编码的字符串添加到 JavaScript 代码中,并将其作为字符串传递给 WebSocket 构造函数的第二个参数。
const cert = `
-----BEGIN CERTIFICATE-----
Base64-encoded certificate content
-----END CERTIFICATE-----
`;
const socket = new WebSocket('wss://example.com', cert);
实际上从参数定义可以知道,第二个参数只支持使用的协议,是不支持证书的。
再附将 BKS 证书转成 Base64 字符串的命令
#先将 BKS 证书转换为 PEM 格式
keytool -importkeystore -srckeystore socket_keystore.bks -srcstoretype BKS -destkeystore socket_keystore.p12 -deststoretype PKCS12
openssl pkcs12 -in socket_keystore.p12 -out socket_keystore.pem -nodes
#将 PEM 格式的证书转换为 Base64 编码的字符串
cat socket_keystore.pem | base64
仔细思考一下,是否信任证书是浏览器的行为,H5页面是没有能力绕过浏览器决定是否信任证书的,只能修改浏览器的逻辑实现。
根据之前的方案调研,H5连接设备展示实时画面的技术卡点是:
预发和线上环境都是 HTTPS 环境。在 HTTPS 站点调用某些非 SSL 验证的资源时浏览器可能会阻止。因此 WebSocket 也必须支持 SSL协议。
由于手机和设备联通是局域网环境,WebSocket 服务启用 SSL 支持无法使用公网 CA 证书,需要申请自签名证书。
浏览器无法直接信任自签名证书,需要用户手动信任,对于用户的操作要求较高。而且测试时还发现,不同浏览器对于“是否信任证书”的提示不同,而苹果手机根本不显示该提示。
进一步发现,“自研App”底层网络库禁掉了自签名证书,如果需要开启自签名证书支持,需要兄弟团队修改底层网络库,推动难度大。
经过多种方案调研,发现不论是推动兄弟团队开启自签名证书支持,还是推动兄弟团队提供 native 层的 websocket 连接绕过 H5 的 HTTPS 限制,还是通过服务端中转数据,从开发投入、用户体验等考虑,都不是很合适。
后来我们意识到,既然问题最源头的原因是 HTTPS 限制了必须走 SSL 验证,那么如果不使用 HTTPS,而是使用 HTTP 环境,不就解决问题了。
经与安全同学沟通,只在实时画面展示这一个页面(不涉及用户数据)使用 HTTP 的环境,是可以的。
因此,最终确定的方案是:
由于 HTTPS 环境也不允许直接打开 HTTP 页面,因此通过打开新的浏览器的方式,展示实时画面页面。
该页面走 HTTP 请求,也就绕过了证书验证流程,可以通过普通 WebSocket 传输数据。
这个新窗口应该限制只看实时画面,不传输任何用户数据,也不做任何业务交互,所有的业务操作还是得回到原来的 HTTPS 环境下。