移除 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:
+5
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ npm start # 或 node server.js
|
|||||||
|
|
||||||
## 部署到 NAS(Docker)
|
## 部署到 NAS(Docker)
|
||||||
|
|
||||||
兩個容器:`360-player` 是應用本體(`node:22-alpine`,內含 ffmpeg),
|
單一容器 `360-player`:以 `node:22-alpine` 為基礎,內含 ffmpeg,TLS 由 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`,明文那條不會離開容器內網。
|
兩個實作細節:
|
||||||
|
|
||||||
|
- 中介憑證要放進 `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 連
|
代價是憑證綁網域,區網也得用網域連(`https://jianmiau.tk:8443`)而不是 IP —— 用 IP 連
|
||||||
會跳憑證警告。真的想要區網免警告的快速通道,把 app 的 `ports` 加回來並綁死區網介面
|
會跳憑證警告。真的想要區網免警告的快速通道,在 compose 的 `ports` 補一條並綁死區網介面
|
||||||
(`"192.168.0.15:8360:8360"`),不要開成 `0.0.0.0`,否則等於在外網開了一條明文路徑。
|
(`"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 小時,否則轉檔跑到一半進度就不再更新。
|
|
||||||
|
|
||||||
**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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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;容器內的 8360(HTTP)不對外開,只給 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 的 preset;NAS CPU 弱可改 superfast / ultrafast(檔案會稍大)
|
# CPU 轉檔時 libx264 的 preset;NAS 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 J4025(UHD 600),已啟用 VAAPI 硬體轉檔;
|
# 本機為 Intel J4025(UHD 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"
|
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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}"
|
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user