Nginx 在这个项目里不扛流,它只发几张页面和一份证书。 但没有它,别人的浏览器根本不给摄像头权限。
13.3.2 装好了信令,这一节装发页面的服务。 13.3.4 把页面、信令、媒体三样串起来。
一、三个服务各管一段
一个 WebRTC 页面要三样东西,通常由三个服务分别提供:
东西 谁提供 端口 能不能合并
HTML 与 JS 静态文件 Nginx 80/443 可以,但它不该扛流 信令(WebSocket) SignalMaster 8080 必须单独进程 媒体(RTP/SRTP) 浏览器直连,或 TURN 动态 不走 HTTP
注意最后一行:媒体根本不经过 Web 服务器。 这就是 WebRTC 和 HLS 最大的架构差别——HLS 的流是被 Nginx 当文件发出去的,WebRTC 的流是浏览器之间直接跑的。
所以 Nginx 在这个项目里只干一件事:把那几张 html 和 js 发出去。 另外它还干第二件事——发证书。这一件比第一件重要得多。
二、类型给错,白屏没商量
浏览器判断一个文件怎么处理,靠的是响应头里的 Content-Type,不是扩展名。 这里用本机的 MIME 表查一遍,六种关键文件该是什么:
扩展名 正确的 Content-Type 给错了会怎样
.html text/html 正常 .js text/javascript 给成 text/plain 模块直接加载失败 .css text/css 给错只是样式丢了 .json application/json fetch 的 json() 会失败 .m3u8 application/vnd.apple.mpegurl 给成 text/plain,Safari 不播 .wasm application/wasm 给错,WebAssembly 拒绝编译
最容易出事的是 .js。
Nginx 的 mime.types 里,.js 长期写的是 application/javascript,
新版才改成 text/javascript;而 .mjs 很久都没被收进去。
偏偏 <script type="module"> 对类型是强检查的:
拿到 text/plain 会直接报「模块脚本的 MIME 类型不允许」,整页白屏。
第二容易出事的是 .m3u8:
Safari 的原生 HLS 播放器只认 application/vnd.apple.mpegurl,
给成 text/plain 它连请求都不发。
这两个坑的共同点是:改配置之前先 curl -I 看一眼响应头,一看就明白。
三、不是所有地址都能开摄像头
getUserMedia 只在「安全上下文」里可用。这条限制不在你的代码里,在浏览器里。
页面地址 能不能开摄像头 原因
http://localhost 算安全 浏览器把它列进可信来源白名单 http://127.0.0.1 算安全 同上 https://任意域名 算安全 证书必须被信任 http://192.168.1.10 不算 局域网也是明文,摄像头不给 file:///D:/a.html 不算 origin 是空的,权限系统给不出主体
所以「我在本机试好好的,同事一访问就不出画面」这句话,
九成是因为你给他的是 http://192.168.x.x。
局域网地址不算安全上下文,浏览器连权限弹窗都不弹——
它直接让 navigator.mediaDevices 变成 undefined,
你调 getUserMedia 得到的是「读不到 undefined 的属性」,不是权限被拒。
这个报错最误导人:看起来像代码写错了,实际是地址不对。
所以看到 Cannot read properties of undefined,第一件事看 URL 的 scheme。
四、证书:浏览器只认 SAN
自签证书有两个字段会打架:Subject 里的 CN,和扩展里的 SAN。
早期浏览器看 CN,现在的浏览器只认 SAN。
证书 有 SAN 吗 SAN 内容 文件字节
只写 CN 没有 - 1115 加了 addext 有 DNS:localhost, IP Address:127.0.0.1 1151
这里有个反直觉的地方:只写 CN 的那张,openssl verify 居然通过了。
因为 OpenSSL 在没有 SAN 的时候会退回用 CN 比,这是它的历史兼容行为。
但浏览器不会退——Chrome 从 58 版起就完全不看 CN 了。
所以「openssl 说没问题、浏览器还是红叉」不是玄学,是两边规则不一样。 结论很干脆:自签证书一定要带 SAN,而且要把 IP 也写进去。
自签证书能用,但每个新观众都要手工点一次「继续前往」,不适合对外开放。 要彻底解决只有一条路:申请真证书。 有域名的用 Let's Encrypt 免费签,没域名的根本没法签—— 因为证书只签给域名,不签给 IP。 这就是为什么所有 WebRTC 教程都默认你有一台有域名的云服务器。
五、动手 Demo:安装 Web 服务器 Nginx——它在这个项目里干什么
先查六种静态文件的 MIME,再真生成两张证书对比 SAN。 最后真起一个 HTTPS 服务连一次,看握手要多久。
import mimetypes
for ext in (".html", ".js", ".css", ".json", ".m3u8", ".wasm"):
print(ext, mimetypes.guess_type("a" + ext)[0])
# 一张只有 CN,一张带 SAN
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", k1, "-out", c1, "-days", "1", "-nodes",
"-subj", "/CN=localhost"])
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", k2, "-out", c2, "-days", "1", "-nodes",
"-subj", "/CN=localhost",
"-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1"])
# 让 openssl 自己判一遍认不认
for label, cf in (("只写 CN", c1), ("加了 SAN", c2)):
r = subprocess.run(["openssl", "verify", "-CAfile", cf,
"-verify_hostname", "localhost", cf],
capture_output=True)
print(label, (r.stdout + r.stderr).decode().strip())
# 真起一个 HTTPS 服务,再当客户端连一次
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(c2, k2)
ls = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
ls.bind(("127.0.0.1", 0))
ls.listen(4)
port = ls.getsockname()[1]
cli = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
cli.check_hostname = False # 自签证书,客户端只能关校验
cli.verify_mode = ssl.CERT_NONE
s = socket.create_connection(("127.0.0.1", port), timeout=5)
w = cli.wrap_socket(s, server_hostname="localhost")
实跑输出:
--------------------------------------------------------------------
安装 Web 服务器 Nginx——它在这个项目里干什么
--------------------------------------------------------------------
**① 一个 WebRTC 页面要三样东西,通常由三个服务分别提供**
东西 谁提供 端口 能不能合并
----------------------------------------------------------------------------------
HTML 与 JS 静态文件 Nginx 80/443 可以,但它不该扛流
信令(WebSocket) SignalMaster 8080 必须单独进程
媒体(RTP/SRTP) 浏览器直连,或 TURN 动态 不走 HTTP
**注意最后一行:媒体根本不经过 Web 服务器。**
**这就是 WebRTC 和 HLS 最大的架构差别**——
**HLS 的流是被 Nginx 当文件发出去的,WebRTC 的流是浏览器之间直接跑的。**
**所以 Nginx 在这个项目里只干一件事:把那几张 html 和 js 发出去。**
**② 但它发的「类型」必须对,这是最常见的坑**
**浏览器判断一个文件怎么处理,靠的是响应头里的 Content-Type,不是扩展名。**
**这里用本机的 MIME 表查一遍,看看六种关键文件该是什么:**
扩展名 正确的 Content-Type 给错了会怎样
---------------------------------------------------------------------------------
.html text/html 正常
.js text/javascript 给成 text/plain 模块直接加载失败
.css text/css 给错只是样式丢了
.json application/json fetch 的 json() 会失败
.m3u8 application/vnd.apple.mpegurl 给成 text/plain,Safari 不播
.wasm application/wasm 给错,WebAssembly 拒绝编译
**最容易出事的是 `.js`。**
**Nginx 的 mime.types 里,`.js` 长期写的是 `application/javascript`,**
**新版才改成 `text/javascript`;而 `.mjs` 很久都没被收进去。**
**偏偏 `<script type="module">` 对类型是强检查的:**
**拿到 `text/plain` 会直接报「模块脚本的 MIME 类型不允许」,整页白屏。**
**第二容易出事的是 `.m3u8`:**
**Safari 的原生 HLS 播放器只认 `application/vnd.apple.mpegurl`,**
**给成 `text/plain` 它连请求都不发。**
**这两个坑的共同点是:改配置之前先 `curl -I` 看一眼响应头,一看就明白。**
**③ 更硬的一条:不是所有地址都能开摄像头**
**`getUserMedia` 只在「安全上下文」里可用。这条限制不在你的代码里,在浏览器里。**
页面地址 能不能开摄像头 原因
--------------------------------------------------------------------------------
http://localhost 算安全 浏览器把它列进可信来源白名单
http://127.0.0.1 算安全 同上
https://任意域名 算安全 证书必须被信任
http://192.168.1.10 不算 局域网也是明文,摄像头不给
file:///D:/a.html 不算 origin 是空的,权限系统给不出主体
**所以「我在本机试好好的,同事一访问就不出画面」这句话,**
**九成是因为你给他的是 `http://192.168.x.x`。**
**局域网地址不算安全上下文,浏览器连权限弹窗都不弹——**
**它直接让 `navigator.mediaDevices` 变成 undefined,**
**你调 `getUserMedia` 得到的是「读不到 undefined 的属性」,不是权限被拒。**
**解法只有一条:上 HTTPS。让对方把页面下到本机去双击打开,那不叫部署。**
**这也是为什么这一节的标题里非要有「安装 Web 服务器」——**
**不是为了发文件,是为了发证书。**
**④ 真生成两张证书,看浏览器为什么不认 CN**
**自签证书有两个字段会打架:`Subject` 里的 CN,和扩展里的 SAN。**
**早期浏览器看 CN,现在的浏览器只认 SAN。这里真生成两张对比:**
证书 有 SAN 吗 SAN 内容 文件字节
----------------------------------------------------------------------------------
只写 CN 没有 - 1115
加了 addext 有 DNS:localhost, IP Address:127.0.0.1 1151
**两张证书的 Subject 都是 `CN=localhost`,唯一的差别就是那一行 SAN。**
**字节数差 36 个,也就是那几十个字符。**
**再用 openssl 自己校验一遍,看它认不认:**
证书 校验方式 校验对象 结果
------------------------------------------------
只写 CN 按域名 localhost 通过
加了 SAN 按域名 localhost 通过
加了 SAN 按 IP 127.0.0.1 通过
**这里有个反直觉的地方:只写 CN 的那张,`openssl verify` 居然通过了。**
**因为 OpenSSL 在没有 SAN 的时候会退回用 CN 比,这是它的历史兼容行为。**
**但浏览器不会退——Chrome 从 58 版起就完全不看 CN 了。**
**所以「openssl 说没问题、浏览器还是红叉」不是玄学,是两边规则不一样。**
**结论很干脆:自签证书一定要带 SAN,而且要把 IP 也写进去。**
**⑤ 真连一次 HTTPS,看握手花了多久**
**握手(含 TCP 建连)最快一次 5 毫秒,拿到 51 字节响应。**
**证书 1151 字节、私钥 1704 字节。**
**这里客户端故意把校验关掉了,所以连得上。**
**真浏览器不会关——它会把这张自签证书标成「不安全」,要人手工点「继续前往」。**
**点了之后页面才算安全上下文,摄像头才给。**
**所以自签证书这条路能用,但每个新观众都要点一次,不适合对外开放。**
**要彻底解决只有一条路:申请真证书。**
**有域名的用 Let's Encrypt 免费签,没域名的根本没法签——**
**因为证书只签给域名,不签给 IP。**
**这就是为什么所有 WebRTC 教程都默认你有一台有域名的云服务器。**
六种类型里,.js 给的是 text/javascript,.m3u8 是 application/vnd.apple.mpegurl。
这两个正是最容易在 Nginx 上配错的。
两张证书的 Subject 都是 CN=localhost,唯一差别是那一行 SAN。
字节数 1115 对 1151,差 36 个,就是那几十个字符。
三次 openssl 校验全部通过——包括那张没有 SAN 的。
因为 OpenSSL 会退回用 CN 比,浏览器不会。这就是两边规则不一样的地方。
真起 HTTPS 服务连一次,环回上握手 16 毫秒,证书 1151 字节、私钥 1704 字节。
30 秒实验
- 用只写 CN 的证书去访问,猜浏览器报什么——
NET::ERR_CERT_COMMON_NAME_INVALID,现代浏览器不看 CN。 - 把
.js的 Content-Type 配成text/plain,猜页面什么现象——白屏,控制台报「模块脚本的 MIME 类型不允许」。 - 用
http://192.168.1.10打开页面,猜navigator.mediaDevices是什么——undefined,所以调getUserMedia直接抛类型错误。
常见误区
- 「Nginx 在这个项目里负责转发媒体」——媒体根本不走 HTTP。
- 「有 CN 就能过证书校验」——现代浏览器只认 SAN。
- 「局域网 http 也能开摄像头」——不算安全上下文,直接 undefined。