🎬 第十三章 WebRTC网页直播
📄 一句话概括

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 秒实验

  1. 用只写 CN 的证书去访问,猜浏览器报什么——NET::ERR_CERT_COMMON_NAME_INVALID,现代浏览器不看 CN。
  2. 把 .js 的 Content-Type 配成 text/plain,猜页面什么现象——白屏,控制台报「模块脚本的 MIME 类型不允许」。
  3. 用 http://192.168.1.10 打开页面,猜 navigator.mediaDevices 是什么——undefined,所以调 getUserMedia 直接抛类型错误。

常见误区

  1. 「Nginx 在这个项目里负责转发媒体」——媒体根本不走 HTTP。
  2. 「有 CN 就能过证书校验」——现代浏览器只认 SAN。
  3. 「局域网 http 也能开摄像头」——不算安全上下文,直接 undefined。
自测三问(点开对答案) 1. WebRTC 项目里 Nginx 干什么? —— **发 HTML 与 JS 静态文件,以及发证书**,不扛流。 2. 为什么 `.js` 的 MIME 给错会白屏? —— **`