移除 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:
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user