移除 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
+5 -2
View File
@@ -9,9 +9,11 @@ WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
PORT=8360 \ PORT=8360 \
HTTPS_PORT=8443 \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
VIDEO_DIR=/videos \ VIDEO_DIR=/videos \
CACHE_DIR=/cache CACHE_DIR=/cache \
SSL_CERT_DIR=/certs
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
@@ -21,8 +23,9 @@ COPY lib ./lib
COPY public ./public COPY public ./public
VOLUME ["/videos", "/cache"] VOLUME ["/videos", "/cache"]
EXPOSE 8360 EXPOSE 8443
# HEALTHCHECK 走容器內的 HTTP,不受憑證影響(8360 不對外開)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ 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 CMD wget -qO- http://127.0.0.1:8360/api/config >/dev/null || exit 1
+30 -25
View File
@@ -48,14 +48,14 @@ npm start # 或 node server.js
## 部署到 NASDocker ## 部署到 NASDocker
兩個容器`360-player` 是應用本體(`node:22-alpine`,內含 ffmpeg 單一容器 `360-player`:以 `node:22-alpine` 為基礎,內含 ffmpegTLS 由 Node 自己終結。
`360-player-web` 是 nginx,負責終結 TLS 再轉給前者。資料夾都用 volume 掛進容器: 資料夾都用 volume 掛進容器:
| 容器 | 容器內路徑 | 用途 | 對應環境變數 | | 容器內路徑 | 用途 | 對應環境變數 |
|---|---|---|---| |---|---|---|
| `360-player` | `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` | | `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` |
| `360-player` | `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` | | `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` |
| `360-player-web` | `/etc/nginx/certs` | SSL 憑證(唯讀) | `SSL_CERT_DIR` | | `/certs` | SSL 憑證(唯讀) | `SSL_CERT_DIR` |
```bash ```bash
# 1. 把整個專案資料夾複製到 NAS,例如 /volume1/docker/360_player # 1. 把整個專案資料夾複製到 NAS,例如 /volume1/docker/360_player
@@ -71,9 +71,9 @@ Synology Container Manager:「專案」→「新增」→ 選這個資料夾
### SSL ### SSL
TLS 由 `360-player-web`(nginx)終結,Node 那端維持純 HTTP,所以應用程式碼裡沒有任何憑證邏輯 TLS 由 Node 直接處理(`server.js`),沒有額外的反向代理容器
憑證放在 `SSL_CERT_DIR`(預設 `/volume1/docker/certs`,和其他專案共用同一份), 憑證放在 `SSL_CERT_DIR`NAS 上預設 `/volume1/docker/certs`,和其他專案共用同一份),
唯讀掛進 nginx 容器: 唯讀掛進容器`/certs`
``` ```
cert.pem 伺服器憑證 cert.pem 伺服器憑證
@@ -81,21 +81,27 @@ chain.pem 中介憑證
privkey.pem 私鑰 privkey.pem 私鑰
``` ```
檔名可用 `SSL_CERT_FILE_NAME` / `SSL_CHAIN_FILE_NAME` / `SSL_KEY_FILE_NAME` 檔名可用 `SSL_CERT_FILE_NAME` / `SSL_CHAIN_FILE_NAME` / `SSL_KEY_FILE_NAME`
容器啟動時 entrypoint 會把 `cert.pem` + `chain.pem` 接成 fullchain(順便去掉 CRLF), DSM 匯出的是 `RSA-cert.pem` 這種名字)。啟動 log 會印出憑證的網域與剩餘天數。
並用 `inotifywait` 盯著這個資料夾 —— **DSM 續期後直接覆蓋檔案就好,nginx 會自己 reload,不用重開容器**
啟動 log 會印出憑證的 subject 和到期日。
兩點注意: **續期不用重啟。** `server.js``fs.watch` 盯著憑證資料夾,檔案一被覆蓋就呼叫
`server.setSecureContext()` 熱套用新憑證 —— 這點很重要,因為重啟會殺掉正在跑的
ffmpeg 轉檔,而一部 4K 360 影片動輒轉好幾小時。
- **對外只有 8443 一個入口**:app 容器只 `expose``ports`,明文那條不會離開容器內網。 兩個實作細節:
代價是憑證綁網域,區網也得用網域連(`https://jianmiau.tk:8443`)而不是 IP —— 用 IP 連
會跳憑證警告。真的想要區網免警告的快速通道,把 app 的 `ports` 加回來並綁死區網介面 - 中介憑證要放進 `cert` 而不是 `ca`。Node 只會把 `cert` 裡的內容送給瀏覽器,
`"192.168.0.15:8360:8360"`),不要開成 `0.0.0.0`,否則等於在外網開了一條明文路徑。 `ca` 是用來驗證對方憑證的信任庫、不會送出;放錯位置會變成不完整的憑證鏈,
- nginx 這邊關掉了 `proxy_buffering` 並把 `proxy_max_temp_file_size` 設為 0 部分客戶端(尤其 Android)會驗證失敗。程式裡是把 cert 和 chain 串成 fullchain
影片是靠 HTTP Range 串流的,開著緩衝會讓 nginx 先把整段回應寫成暫存檔再吐出去, - 串接前會正規化 PEM(去掉 CRLF、補上結尾換行),否則兩個檔黏成一行會解析不出來。
幾 GB 的來源會直接塞爆容器磁碟。`/api/events`SSE)另外把 `proxy_read_timeout`
拉到 24 小時,否則轉檔跑到一半進度就不再更新。 `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 上轉檔速度** **NAS 上轉檔速度**
- 沒有 GPU 時走 `libx264`CPU)。NAS 的 CPU 把 4K 360 影片同時轉成三種畫質大約只有個位數 fps, - 沒有 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/probe.js ffprobe 包裝 + 永續快取(含 360 metadata 判斷)
lib/transcode.js ffmpeg 工作佇列(一次解碼多輸出;CUDA → NVENC → CPU 備援) lib/transcode.js ffmpeg 工作佇列(一次解碼多輸出;CUDA → NVENC → CPU 備援)
lib/spherical.js 把 Spherical Video V1 metadata 注回 MP4 lib/spherical.js 把 Spherical Video V1 metadata 注回 MP4
public/ 前端(Three.js 球體貼圖播放器) public/ 前端(Three.js 球體貼圖播放器)
docker/nginx/ nginx 映像檔:終結 TLS,反向代理到 app(憑證變更自動 reload)
cache/ 轉檔輸出與 probe 快取(已 gitignore cache/ 轉檔輸出與 probe 快取(已 gitignore
``` ```
+2
View File
@@ -3,6 +3,8 @@
"host": "0.0.0.0", "host": "0.0.0.0",
"videoDir": "/volume1/photo/Badminton", "videoDir": "/volume1/photo/Badminton",
"cacheDir": "./cache", "cacheDir": "./cache",
"httpsPort": 0,
"certDir": "./certificate",
"extensions": [".mp4", ".mov", ".m4v", ".webm"], "extensions": [".mp4", ".mov", ".m4v", ".webm"],
"qualities": [ "qualities": [
{ "id": "2560", "label": "高", "width": 2560, "bitrate": "10M", "maxrate": "13M" }, { "id": "2560", "label": "高", "width": 2560, "bitrate": "10M", "maxrate": "13M" },
+10 -31
View File
@@ -6,47 +6,26 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: 360-player:latest image: 360-player:latest
restart: unless-stopped restart: unless-stopped
# 不對外開埠:只透過容器內網讓 nginx 連得到,外面一律走 HTTPS。 ports:
# 若要在區網用 IP 直連明文(會省掉憑證警告),自行加回: # 對外只有 HTTPS;容器內的 8360HTTP)不對外開,只給 HEALTHCHECK 用。
# ports: - "${HTTPS_PORT:-8443}:8443"
# - "127.0.0.1:${PORT:-8360}:8360" # 或綁區網 IP
expose:
- "8360"
volumes: volumes:
# 影片資料夾(NAS 上的實際路徑)→ 容器內 /videos,唯讀 # 影片資料夾(NAS 上的實際路徑)→ 容器內 /videos,唯讀
- "${VIDEO_DIR:-/volume1/photo/Badminton}:/videos:ro" - "${VIDEO_DIR:-/volume1/photo/Badminton}:/videos:ro"
# 轉檔輸出 + probe 快取 → 容器內 /cache(需要可寫) # 轉檔輸出 + probe 快取 → 容器內 /cache(需要可寫)
- "${CACHE_DIR:-./cache}:/cache" - "${CACHE_DIR:-./cache}:/cache"
# SSL 憑證(與 badminton-scoreboard 共用同一份)→ 容器內 /certs,唯讀。
# 左邊是 NAS 上的路徑,右邊是容器內固定的 /certs。
- "${SSL_CERT_DIR:-/volume1/docker/certs}:/certs:ro"
environment: environment:
TZ: ${TZ:-Asia/Taipei} TZ: ${TZ:-Asia/Taipei}
# CPU 轉檔時 libx264 的 presetNAS CPU 弱可改 superfast / ultrafast(檔案會稍大) # CPU 轉檔時 libx264 的 presetNAS CPU 弱可改 superfast / ultrafast(檔案會稍大)
X264_PRESET: ${X264_PRESET:-veryfast} 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 J4025UHD 600),已啟用 VAAPI 硬體轉檔; # 本機為 Intel J4025UHD 600),已啟用 VAAPI 硬體轉檔;
# 換到沒有 /dev/dri 的機器時要把下面兩行註解掉,否則容器起不來。 # 換到沒有 /dev/dri 的機器時要把下面兩行註解掉,否則容器起不來。
devices: devices:
- /dev/dri:/dev/dri - /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"
-10
View File
@@ -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"]
-124
View File
@@ -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 <<EOF
server {
listen ${NGINX_PORT} ssl;
server_name ${NGINX_SERVER_NAME};
ssl_certificate ${GENERATED_CERT_PATH};
ssl_certificate_key ${GENERATED_KEY_PATH};
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# 影片串流:一定要關掉緩衝。開著的話 nginx 會先把整段 Range 回應寫進
# 暫存檔再吐給瀏覽器,15 GB 的來源會把容器磁碟塞爆,拖動進度條也會卡住。
proxy_buffering off;
proxy_max_temp_file_size 0;
location / {
proxy_pass http://${UPSTREAM_HOST}:${UPSTREAM_PORT};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# /api/events 是 SSE,轉檔跑好幾小時期間連線要一直開著;
# 預設 60 秒就會被切斷,進度就不會再更新了。
proxy_read_timeout 24h;
proxy_send_timeout 24h;
}
}
EOF
}
watch_cert_updates() {
while inotifywait -qq -e close_write,create,delete,move "${SSL_CERT_DIR}"; do
echo "Certificate files changed, reloading nginx..."
build_cert_bundle
nginx -s reload
done
}
build_cert_bundle
write_nginx_config
nginx -t
watch_cert_updates &
WATCHER_PID=$!
cleanup() {
kill "${WATCHER_PID}" 2>/dev/null || true
}
trap cleanup INT TERM
nginx -g 'daemon off;' &
NGINX_PID=$!
wait "${NGINX_PID}"
+92 -3
View File
@@ -1,7 +1,10 @@
import express from 'express'; import express from 'express';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import https from 'node:https';
import { X509Certificate } from 'node:crypto';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { ProbeCache } from './lib/probe.js'; import { ProbeCache } from './lib/probe.js';
import { Transcoder, MODE_LABEL } from './lib/transcode.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.HOST) config.host = process.env.HOST;
if (process.env.VIDEO_DIR) config.videoDir = process.env.VIDEO_DIR; 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.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 VIDEO_DIR = path.resolve(__dirname, config.videoDir);
const CACHE_DIR = path.resolve(__dirname, config.cacheDir); 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 EXT = new Set((config.extensions || ['.mp4']).map(e => e.toLowerCase()));
const QUALITY_IDS = new Set(config.qualities.map(q => q.id)); const QUALITY_IDS = new Set(config.qualities.map(q => q.id));
@@ -207,17 +219,91 @@ app.use((err, req, res, next) => {
}); });
// ---- Start ---- // ---- 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(); await probeCache.load();
const modes = await transcoder.init(); 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 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(`360 Player`);
console.log(` 影片資料夾 : ${VIDEO_DIR}`); console.log(` 影片資料夾 : ${VIDEO_DIR}`);
console.log(` 轉檔快取 : ${CACHE_DIR}`); console.log(` 轉檔快取 : ${CACHE_DIR}`);
console.log(` 轉檔引擎 : ${MODE_LABEL[modes[0]]} (備援: ${modes.slice(1).map(m => MODE_LABEL[m]).join(' → ') || '無'})`); 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 [ifname, addrs] of Object.entries(os.networkInterfaces())) {
for (const a of addrs) { 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, () => { process.on(sig, () => {
console.log(`收到 ${sig},關閉中…`); console.log(`收到 ${sig},關閉中…`);
if (transcoder.running) transcoder.cancel(transcoder.running.id); 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(); setTimeout(() => process.exit(0), 3000).unref();
}); });
} }