移除 nginx 容器,改由 Node 直接終結 TLS,合併成單一容器

摘要:
拿掉 360-player-web(nginx)服務與 docker/nginx/,改在 server.js 用
node:https 直接提供 HTTPS,部署從兩個容器變成一個。

根本原因:
先前為了 TLS 而多開一個 nginx 容器。分開的主因是「憑證續期時能 reload
而不重啟 app」——重啟會殺掉正在跑的 ffmpeg 轉檔,一部 4K 360 影片動輒數小時。
但 Node 的 https.Server 本來就有 setSecureContext(),可以熱換憑證不重啟行程,
這個理由不成立,多一個容器只是多一層維護成本。

影響:
- 需維護額外的 nginx 映像檔與 entrypoint.sh
- 影片經反向代理多一跳,且必須小心處理 proxy_buffering 與 SSE 逾時,
  設錯會讓 Range 串流被寫進暫存檔、或讓轉檔進度停止更新

修法:
- server.js 新增 TLS:讀取 SSL_CERT_DIR 的憑證,以 fs.watch 監看該資料夾,
  檔案變動時 debounce 1 秒後呼叫 setSecureContext() 熱套用
- 中介憑證串進 cert 而非 ca:Node 只送出 cert 的內容,ca 是驗證對方用的
  信任庫、不會送給瀏覽器,放錯會導致憑證鏈不完整
- 串接前正規化 PEM(去 CRLF、補結尾換行),沿用原 nginx entrypoint 的處理
- config.json 新增 httpsPort(預設 0 = 停用)與 certDir,本機開發不需憑證;
  憑證讀不到時退回只提供 HTTP 並印警告,不讓服務起不來
- docker-compose.yml 併回單一服務,憑證改掛 /certs;Dockerfile 補上
  HTTPS_PORT、SSL_CERT_DIR,EXPOSE 改為 8443
- 刪除 docker/nginx/

驗證(實機執行,非僅靜態檢查):
- 以 SSL_CERT_DIR=/volume1/docker/certs 啟動,log 顯示
  「HTTPS:jianmiau.tk — 14 天後到期」,https 的 /api/config 回 200
- openssl s_client 確認送出完整三層憑證鏈
  (jianmiau.tk → Let's Encrypt YR2 → ISRG Root YR)
- HTTPS 上的 Range 請求正常:檔頭與中段各取一段皆回 206 且長度正確
- 熱換測試:換上 CN=hotswap-test.local 的自簽憑證後,log 出現
  「憑證已重新載入」,s_client 讀到新 CN,且行程 PID 與啟動時間不變

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:09:56 +08:00
co-authored by Claude Opus 5
parent ae3221c460
commit 0a3c548e0b
7 changed files with 139 additions and 195 deletions
+92 -3
View File
@@ -1,7 +1,10 @@
import express from 'express';
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import https from 'node:https';
import { X509Certificate } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { ProbeCache } from './lib/probe.js';
import { Transcoder, MODE_LABEL } from './lib/transcode.js';
@@ -15,8 +18,17 @@ if (process.env.PORT) config.port = Number(process.env.PORT);
if (process.env.HOST) config.host = process.env.HOST;
if (process.env.VIDEO_DIR) config.videoDir = process.env.VIDEO_DIR;
if (process.env.CACHE_DIR) config.cacheDir = process.env.CACHE_DIR;
if (process.env.HTTPS_PORT) config.httpsPort = Number(process.env.HTTPS_PORT);
if (process.env.SSL_CERT_DIR) config.certDir = process.env.SSL_CERT_DIR;
const VIDEO_DIR = path.resolve(__dirname, config.videoDir);
const CACHE_DIR = path.resolve(__dirname, config.cacheDir);
const CERT_DIR = path.resolve(__dirname, config.certDir || './certificate');
// DSM 匯出的檔名是 RSA-cert.pem 之類的,所以檔名可以個別覆寫。
const CERT_FILES = {
cert: process.env.SSL_CERT_FILE_NAME || 'cert.pem',
chain: process.env.SSL_CHAIN_FILE_NAME || 'chain.pem',
key: process.env.SSL_KEY_FILE_NAME || 'privkey.pem',
};
const EXT = new Set((config.extensions || ['.mp4']).map(e => e.toLowerCase()));
const QUALITY_IDS = new Set(config.qualities.map(q => q.id));
@@ -207,17 +219,91 @@ app.use((err, req, res, next) => {
});
// ---- Start ----
// ---- TLS (optional) ----
// Strip CRLF and guarantee a trailing newline, otherwise cert and chain glue together
// into one line when concatenated and the PEM no longer parses.
const normalizePem = (s) => s.replace(/\r\n/g, '\n').replace(/\n*$/, '\n');
/**
* Build a secure context from CERT_DIR. Intermediates belong in `cert`, not `ca`:
* Node only sends what is in `cert`, while `ca` is the trust store used to verify
* peers. Putting the chain in `ca` yields an incomplete chain for some clients.
*/
async function loadTls() {
const read = (n) => fs.readFile(path.join(CERT_DIR, n), 'utf8');
const [cert, key] = await Promise.all([read(CERT_FILES.cert), read(CERT_FILES.key)]);
let chain = '';
try { chain = await read(CERT_FILES.chain); } catch { /* chain is optional */ }
const x = new X509Certificate(cert);
const days = Math.round((new Date(x.validTo) - Date.now()) / 86400000);
return {
context: { cert: normalizePem(cert) + (chain ? normalizePem(chain) : ''), key },
note: `${x.subject.replace(/^CN=/, '')}` +
(days < 0 ? `⚠ 已於 ${x.validTo} 過期` : `${days} 天後到期`),
};
}
/**
* Certificates are mounted from outside and DSM overwrites them on renewal.
* setSecureContext swaps them in place: restarting would kill a transcode that
* may have been running for hours.
*/
function watchCert(srv) {
let timer = null;
try {
fsSync.watch(CERT_DIR, () => {
clearTimeout(timer);
// Renewal rewrites three files; wait for the burst to settle, then apply once.
timer = setTimeout(async () => {
try {
const tls = await loadTls();
srv.setSecureContext(tls.context);
console.log(`憑證已重新載入:${tls.note}`);
} catch (e) {
console.error(`憑證重新載入失敗:${e.message}`);
}
}, 1000);
});
} catch (e) {
console.warn(`無法監看憑證資料夾(${e.message}),續期後需手動重啟容器`);
}
}
await probeCache.load();
const modes = await transcoder.init();
// A missing or broken certificate must not stop the player from serving over HTTP.
let httpsServer = null;
let tlsNote = '未啟用';
if (config.httpsPort) {
try {
const tls = await loadTls();
httpsServer = https.createServer(tls.context, app);
httpsServer.keepAliveTimeout = 65000;
await new Promise((resolve, reject) => {
httpsServer.once('error', reject);
httpsServer.listen(config.httpsPort, config.host, resolve);
});
watchCert(httpsServer);
tlsNote = tls.note;
} catch (e) {
httpsServer = null;
tlsNote = `⚠ 讀不到憑證(${CERT_DIR}):${e.code || e.message},只提供 HTTP`;
}
}
const server = app.listen(config.port, config.host, () => {
const proto = httpsServer ? 'https' : 'http';
const port = httpsServer ? config.httpsPort : config.port;
console.log(`360 Player`);
console.log(` 影片資料夾 : ${VIDEO_DIR}`);
console.log(` 轉檔快取 : ${CACHE_DIR}`);
console.log(` 轉檔引擎 : ${MODE_LABEL[modes[0]]} (備援: ${modes.slice(1).map(m => MODE_LABEL[m]).join(' → ') || '無'})`);
console.log(` 網址 : http://localhost:${config.port}`);
console.log(` HTTPS : ${tlsNote}`);
console.log(` 網址 : ${proto}://localhost:${port}`);
for (const [ifname, addrs] of Object.entries(os.networkInterfaces())) {
for (const a of addrs) {
if (a.family === 'IPv4' && !a.internal) console.log(` http://${a.address}:${config.port} (${ifname})`);
if (a.family === 'IPv4' && !a.internal) console.log(` ${proto}://${a.address}:${port} (${ifname})`);
}
}
});
@@ -228,7 +314,10 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
console.log(`收到 ${sig},關閉中…`);
if (transcoder.running) transcoder.cancel(transcoder.running.id);
server.close(() => process.exit(0));
let pending = httpsServer ? 2 : 1;
const done = () => { if (--pending === 0) process.exit(0); };
server.close(done);
httpsServer?.close(done);
setTimeout(() => process.exit(0), 3000).unref();
});
}