diff --git a/Dockerfile b/Dockerfile index b16cc57..044af0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,11 @@ WORKDIR /app ENV NODE_ENV=production \ PORT=8360 \ + HTTPS_PORT=8443 \ HOST=0.0.0.0 \ VIDEO_DIR=/videos \ - CACHE_DIR=/cache + CACHE_DIR=/cache \ + SSL_CERT_DIR=/certs COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force @@ -21,8 +23,9 @@ COPY lib ./lib COPY public ./public VOLUME ["/videos", "/cache"] -EXPOSE 8360 +EXPOSE 8443 +# HEALTHCHECK 走容器內的 HTTP,不受憑證影響(8360 不對外開)。 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD wget -qO- http://127.0.0.1:8360/api/config >/dev/null || exit 1 diff --git a/README.md b/README.md index d6d2c67..f1112ac 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,14 @@ npm start # 或 node server.js ## 部署到 NAS(Docker) -兩個容器:`360-player` 是應用本體(`node:22-alpine`,內含 ffmpeg), -`360-player-web` 是 nginx,負責終結 TLS 再轉給前者。資料夾都用 volume 掛進容器: +單一容器 `360-player`:以 `node:22-alpine` 為基礎,內含 ffmpeg,TLS 由 Node 自己終結。 +資料夾都用 volume 掛進容器: -| 容器 | 容器內路徑 | 用途 | 對應環境變數 | -|---|---|---|---| -| `360-player` | `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` | -| `360-player` | `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` | -| `360-player-web` | `/etc/nginx/certs` | SSL 憑證(唯讀) | `SSL_CERT_DIR` | +| 容器內路徑 | 用途 | 對應環境變數 | +|---|---|---| +| `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` | +| `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` | +| `/certs` | SSL 憑證(唯讀) | `SSL_CERT_DIR` | ```bash # 1. 把整個專案資料夾複製到 NAS,例如 /volume1/docker/360_player @@ -71,9 +71,9 @@ Synology Container Manager:「專案」→「新增」→ 選這個資料夾 ### SSL -TLS 由 `360-player-web`(nginx)終結,Node 那端維持純 HTTP,所以應用程式碼裡沒有任何憑證邏輯。 -憑證放在 `SSL_CERT_DIR`(預設 `/volume1/docker/certs`,和其他專案共用同一份), -唯讀掛進 nginx 容器: +TLS 由 Node 直接處理(`server.js`),沒有額外的反向代理容器。 +憑證放在 `SSL_CERT_DIR`(NAS 上預設 `/volume1/docker/certs`,和其他專案共用同一份), +唯讀掛進容器的 `/certs`: ``` cert.pem 伺服器憑證 @@ -81,21 +81,27 @@ chain.pem 中介憑證 privkey.pem 私鑰 ``` -檔名可用 `SSL_CERT_FILE_NAME` / `SSL_CHAIN_FILE_NAME` / `SSL_KEY_FILE_NAME` 改。 -容器啟動時 entrypoint 會把 `cert.pem` + `chain.pem` 接成 fullchain(順便去掉 CRLF), -並用 `inotifywait` 盯著這個資料夾 —— **DSM 續期後直接覆蓋檔案就好,nginx 會自己 reload,不用重開容器**。 -啟動 log 會印出憑證的 subject 和到期日。 +檔名可用 `SSL_CERT_FILE_NAME` / `SSL_CHAIN_FILE_NAME` / `SSL_KEY_FILE_NAME` 改 +(DSM 匯出的是 `RSA-cert.pem` 這種名字)。啟動 log 會印出憑證的網域與剩餘天數。 -兩點注意: +**續期不用重啟。** `server.js` 用 `fs.watch` 盯著憑證資料夾,檔案一被覆蓋就呼叫 +`server.setSecureContext()` 熱套用新憑證 —— 這點很重要,因為重啟會殺掉正在跑的 +ffmpeg 轉檔,而一部 4K 360 影片動輒轉好幾小時。 -- **對外只有 8443 一個入口**:app 容器只 `expose` 不 `ports`,明文那條不會離開容器內網。 - 代價是憑證綁網域,區網也得用網域連(`https://jianmiau.tk:8443`)而不是 IP —— 用 IP 連 - 會跳憑證警告。真的想要區網免警告的快速通道,把 app 的 `ports` 加回來並綁死區網介面 - (`"192.168.0.15:8360:8360"`),不要開成 `0.0.0.0`,否則等於在外網開了一條明文路徑。 -- nginx 這邊關掉了 `proxy_buffering` 並把 `proxy_max_temp_file_size` 設為 0。 - 影片是靠 HTTP Range 串流的,開著緩衝會讓 nginx 先把整段回應寫成暫存檔再吐出去, - 幾 GB 的來源會直接塞爆容器磁碟。`/api/events`(SSE)另外把 `proxy_read_timeout` - 拉到 24 小時,否則轉檔跑到一半進度就不再更新。 +兩個實作細節: + +- 中介憑證要放進 `cert` 而不是 `ca`。Node 只會把 `cert` 裡的內容送給瀏覽器, + `ca` 是用來驗證對方憑證的信任庫、不會送出;放錯位置會變成不完整的憑證鏈, + 部分客戶端(尤其 Android)會驗證失敗。程式裡是把 cert 和 chain 串成 fullchain。 +- 串接前會正規化 PEM(去掉 CRLF、補上結尾換行),否則兩個檔黏成一行會解析不出來。 + +`httpsPort` 設 0(`config.json` 的預設)就完全不啟用 HTTPS,本機 `npm start` +開發時不必準備憑證。憑證讀不到時會印警告並退回只提供 HTTP,不會讓服務起不來。 + +**對外只有 HTTPS 一個入口**:容器內的 8360(HTTP)不對外開,只給 HEALTHCHECK 用。 +代價是憑證綁網域,區網也得用網域連(`https://jianmiau.tk:8443`)而不是 IP —— 用 IP 連 +會跳憑證警告。真的想要區網免警告的快速通道,在 compose 的 `ports` 補一條並綁死區網介面 +(`"192.168.0.15:8360:8360"`),不要開成 `0.0.0.0`,否則等於在外網開了一條明文路徑。 **NAS 上轉檔速度** - 沒有 GPU 時走 `libx264`(CPU)。NAS 的 CPU 把 4K 360 影片同時轉成三種畫質大約只有個位數 fps, @@ -148,11 +154,10 @@ privkey.pem 私鑰 ## 專案結構 ``` -server.js Express:清單、Range 串流、轉檔 API、SSE +server.js Express:清單、Range 串流、轉檔 API、SSE、TLS(憑證熱換) lib/probe.js ffprobe 包裝 + 永續快取(含 360 metadata 判斷) lib/transcode.js ffmpeg 工作佇列(一次解碼多輸出;CUDA → NVENC → CPU 備援) lib/spherical.js 把 Spherical Video V1 metadata 注回 MP4 public/ 前端(Three.js 球體貼圖播放器) -docker/nginx/ nginx 映像檔:終結 TLS,反向代理到 app(憑證變更自動 reload) cache/ 轉檔輸出與 probe 快取(已 gitignore) ``` diff --git a/config.json b/config.json index 54dc22b..9cd72a1 100644 --- a/config.json +++ b/config.json @@ -3,6 +3,8 @@ "host": "0.0.0.0", "videoDir": "/volume1/photo/Badminton", "cacheDir": "./cache", + "httpsPort": 0, + "certDir": "./certificate", "extensions": [".mp4", ".mov", ".m4v", ".webm"], "qualities": [ { "id": "2560", "label": "高", "width": 2560, "bitrate": "10M", "maxrate": "13M" }, diff --git a/docker-compose.yml b/docker-compose.yml index 7540d3b..5705f12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,47 +6,26 @@ services: dockerfile: Dockerfile image: 360-player:latest restart: unless-stopped - # 不對外開埠:只透過容器內網讓 nginx 連得到,外面一律走 HTTPS。 - # 若要在區網用 IP 直連明文(會省掉憑證警告),自行加回: - # ports: - # - "127.0.0.1:${PORT:-8360}:8360" # 或綁區網 IP - expose: - - "8360" + ports: + # 對外只有 HTTPS;容器內的 8360(HTTP)不對外開,只給 HEALTHCHECK 用。 + - "${HTTPS_PORT:-8443}:8443" volumes: # 影片資料夾(NAS 上的實際路徑)→ 容器內 /videos,唯讀 - "${VIDEO_DIR:-/volume1/photo/Badminton}:/videos:ro" # 轉檔輸出 + probe 快取 → 容器內 /cache(需要可寫) - "${CACHE_DIR:-./cache}:/cache" + # SSL 憑證(與 badminton-scoreboard 共用同一份)→ 容器內 /certs,唯讀。 + # 左邊是 NAS 上的路徑,右邊是容器內固定的 /certs。 + - "${SSL_CERT_DIR:-/volume1/docker/certs}:/certs:ro" environment: TZ: ${TZ:-Asia/Taipei} # CPU 轉檔時 libx264 的 preset;NAS CPU 弱可改 superfast / ultrafast(檔案會稍大) X264_PRESET: ${X264_PRESET:-veryfast} + # 憑證檔名(DSM 匯出的是 RSA-cert.pem 之類的,可個別覆寫) + SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem} + SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-chain.pem} + SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-privkey.pem} # 本機為 Intel J4025(UHD 600),已啟用 VAAPI 硬體轉檔; # 換到沒有 /dev/dri 的機器時要把下面兩行註解掉,否則容器起不來。 devices: - /dev/dri:/dev/dri - - 360-player-web: - container_name: 360-player-web - build: - context: . - dockerfile: docker/nginx/Dockerfile - image: 360-player-web:latest - restart: unless-stopped - depends_on: - - 360-player - ports: - - "${HTTPS_PORT:-8443}:8443" - environment: - TZ: ${TZ:-Asia/Taipei} - NGINX_PORT: 8443 - NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_} - SSL_CERT_DIR: /etc/nginx/certs - SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem} - SSL_CHAIN_FILE_NAME: ${SSL_CHAIN_FILE_NAME:-chain.pem} - SSL_KEY_FILE_NAME: ${SSL_KEY_FILE_NAME:-privkey.pem} - UPSTREAM_HOST: 360-player - UPSTREAM_PORT: 8360 - volumes: - # 憑證來源與 badminton-scoreboard 共用;DSM 續期後 entrypoint 會自動 reload - - "${SSL_CERT_DIR:-/volume1/docker/certs}:/etc/nginx/certs:ro" diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile deleted file mode 100644 index 990ef46..0000000 --- a/docker/nginx/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM nginx:1.27-alpine - -# inotify-tools:憑證續期後自動重載 nginx,不用手動重啟容器。 -RUN apk add --no-cache inotify-tools - -COPY docker/nginx/entrypoint.sh /entrypoint.sh - -RUN chmod +x /entrypoint.sh - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh deleted file mode 100644 index 5655d0f..0000000 --- a/docker/nginx/entrypoint.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/bin/sh -set -eu - -NGINX_PORT="${NGINX_PORT:-8443}" -NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-_}" -SSL_CERT_DIR="${SSL_CERT_DIR:-/etc/nginx/certs}" -SSL_CERT_FILE_NAME="${SSL_CERT_FILE_NAME:-cert.pem}" -SSL_CHAIN_FILE_NAME="${SSL_CHAIN_FILE_NAME:-chain.pem}" -SSL_KEY_FILE_NAME="${SSL_KEY_FILE_NAME:-privkey.pem}" -UPSTREAM_HOST="${UPSTREAM_HOST:-360-player}" -UPSTREAM_PORT="${UPSTREAM_PORT:-8360}" - -GENERATED_DIR="/etc/nginx/generated" -GENERATED_CERT_PATH="${GENERATED_DIR}/fullchain.pem" -GENERATED_KEY_PATH="${GENERATED_DIR}/privkey.pem" - -mkdir -p "${GENERATED_DIR}" - -normalize_pem_file() { - pem_path="$1" - - # 去掉 CRLF 並確保結尾有換行,否則 cert 和 chain 接起來會黏成一行、nginx 讀不到。 - awk ' - { - sub(/\r$/, "") - print - has_content = 1 - } - END { - if (has_content) { - print "" - } - } - ' "${pem_path}" -} - -build_cert_bundle() { - cert_path="${SSL_CERT_DIR}/${SSL_CERT_FILE_NAME}" - chain_path="${SSL_CERT_DIR}/${SSL_CHAIN_FILE_NAME}" - key_path="${SSL_CERT_DIR}/${SSL_KEY_FILE_NAME}" - - if [ ! -f "${cert_path}" ]; then - echo "Missing certificate file: ${cert_path}" >&2 - exit 1 - fi - - if [ ! -f "${chain_path}" ]; then - echo "Missing chain file: ${chain_path}" >&2 - exit 1 - fi - - if [ ! -f "${key_path}" ]; then - echo "Missing key file: ${key_path}" >&2 - exit 1 - fi - - normalize_pem_file "${cert_path}" > "${GENERATED_CERT_PATH}" - normalize_pem_file "${chain_path}" >> "${GENERATED_CERT_PATH}" - cp "${key_path}" "${GENERATED_KEY_PATH}" - - # 到期日印在 log 裡,容器一起來就看得到憑證還剩多久。 - openssl x509 -in "${cert_path}" -noout -subject -enddate 2>/dev/null || true -} - -write_nginx_config() { - cat > /etc/nginx/conf.d/default.conf </dev/null || true -} - -trap cleanup INT TERM - -nginx -g 'daemon off;' & -NGINX_PID=$! - -wait "${NGINX_PID}" diff --git a/server.js b/server.js index 0465e87..167e934 100644 --- a/server.js +++ b/server.js @@ -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(); }); }