新增 nginx 容器終結 TLS,讓播放器可用 HTTPS 連線
摘要:
比照 badminton-scoreboard 的做法,加一個 nginx 容器負責 SSL,
反向代理到原本的 Node 應用;Node 端維持純 HTTP,不碰憑證。
根本原因:
播放器原本只提供 HTTP。專案內原先想直接 symlink www/certificate 進來,
但那份憑證是 2026-04-16 從 DSM 複製出來的靜態副本,已於 2026-07-01 過期,
且 DSM 續期後不會自動更新副本。共用目錄 /volume1/docker/certs 才是
其他專案在用、且持續被續期的來源(目前效期至 2026-09-04)。
影響:
- 外網連線全程明文
- 若沿用 www/certificate,瀏覽器會直接因憑證過期而擋下連線
修法:
- 新增 docker/nginx/(Dockerfile + entrypoint.sh):啟動時把 cert.pem 與
chain.pem 正規化並串成 fullchain,依環境變數產生 nginx 設定,
再用 inotifywait 監看憑證目錄,續期後自動 reload,不需重啟容器
- docker-compose.yml 拆成 360-player(app)與 360-player-web(nginx)兩個服務,
憑證以唯讀掛載 ${SSL_CERT_DIR:-/volume1/docker/certs}
- 保留 app 的 HTTP 埠:憑證綁網域,區網用 IP 連 HTTPS 必定跳警告,
維持 區網走 http / 外網走 https 兩條路
- .env.example 補上 SSL_* 與 HTTPS_PORT 設定項
針對本專案調整(與 badminton-scoreboard 不同之處):
- proxy_buffering off + proxy_max_temp_file_size 0:影片走 HTTP Range 串流,
開著緩衝 nginx 會把整段回應先寫成暫存檔,數 GB 來源會塞爆容器磁碟
- proxy_read_timeout/send_timeout 24h:/api/events 是 SSE,轉檔動輒數小時,
預設 60 秒會被切斷導致進度停止更新
- 移除 websocket 的 Upgrade 標頭:本專案用 SSE,沒有 websocket
驗證:
以 entrypoint 相同的 awk 邏輯在本機組出 fullchain,openssl 解析得到完整三層
憑證鏈(jianmiau.tk → Let's Encrypt YR2 → ISRG Root YR),且 cert 與 privkey
的 modulus 相符。docker compose config 展開後的路徑與埠號皆正確。
註:無 docker daemon 權限,未實際 build 與啟動容器。
順帶修正 README:videoDir 範例改為 NAS 路徑、VAAPI 一節更新為已實機驗證。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-1
@@ -6,11 +6,25 @@ VIDEO_DIR=/volume1/photo/Badminton
|
||||
# 轉檔輸出與快取的存放位置(容器的 /cache;每部影片三種畫質約需原檔 70% 的空間)
|
||||
CACHE_DIR=/volume1/docker/360_player/cache
|
||||
|
||||
# 對外埠號(瀏覽器連 http://NAS-IP:8360)
|
||||
# HTTP 埠(區網用 IP 直連;憑證是網域名,用 IP 連 HTTPS 一定會跳警告)
|
||||
PORT=8360
|
||||
|
||||
# HTTPS 埠(由 nginx 容器終結 TLS 後轉給 app)
|
||||
HTTPS_PORT=8443
|
||||
|
||||
# 時區
|
||||
TZ=Asia/Taipei
|
||||
|
||||
# CPU 轉檔的 libx264 preset:veryfast(預設)/ superfast / ultrafast
|
||||
X264_PRESET=veryfast
|
||||
|
||||
# --- SSL ---
|
||||
# 憑證放的資料夾(唯讀掛進 nginx 容器的 /etc/nginx/certs)。
|
||||
# DSM 續期後直接覆蓋這裡的檔案即可,nginx 會自己 reload,不用重開容器。
|
||||
SSL_CERT_DIR=/volume1/docker/certs
|
||||
SSL_CERT_FILE_NAME=cert.pem
|
||||
SSL_CHAIN_FILE_NAME=chain.pem
|
||||
SSL_KEY_FILE_NAME=privkey.pem
|
||||
|
||||
# 憑證上的網域;用 _ 表示不限(任何 Host 都接)
|
||||
NGINX_SERVER_NAME=jianmiau.tk
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 360 Player
|
||||
|
||||
在瀏覽器裡播放 360° 全景影片的本機網頁播放器。
|
||||
從下拉選單挑選 `W:\photo\Badminton` 裡的影片、選擇播放畫質;低畫質版本由 ffmpeg(NVIDIA NVENC)轉出並快取在本機。
|
||||
從下拉選單挑選影片資料夾(`config.json` 的 `videoDir`,NAS 上是 `/volume1/photo/Badminton`)裡的影片、選擇播放畫質;低畫質版本由 ffmpeg(NVIDIA NVENC)轉出並快取在本機。
|
||||
|
||||
## 啟動
|
||||
|
||||
@@ -48,12 +48,14 @@ npm start # 或 node server.js
|
||||
|
||||
## 部署到 NAS(Docker)
|
||||
|
||||
映像檔以 `node:22-alpine` 為基礎,內含 ffmpeg;影片資料夾與快取都用 volume 掛進容器:
|
||||
兩個容器:`360-player` 是應用本體(`node:22-alpine`,內含 ffmpeg),
|
||||
`360-player-web` 是 nginx,負責終結 TLS 再轉給前者。資料夾都用 volume 掛進容器:
|
||||
|
||||
| 容器內路徑 | 用途 | 對應環境變數 |
|
||||
|---|---|---|
|
||||
| `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` |
|
||||
| `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` |
|
||||
| 容器 | 容器內路徑 | 用途 | 對應環境變數 |
|
||||
|---|---|---|---|
|
||||
| `360-player` | `/videos` | 影片資料夾(唯讀) | `VIDEO_DIR` |
|
||||
| `360-player` | `/cache` | 轉檔輸出與 probe 快取(需可寫) | `CACHE_DIR` |
|
||||
| `360-player-web` | `/etc/nginx/certs` | SSL 憑證(唯讀) | `SSL_CERT_DIR` |
|
||||
|
||||
```bash
|
||||
# 1. 把整個專案資料夾複製到 NAS,例如 /volume1/docker/360_player
|
||||
@@ -61,18 +63,48 @@ npm start # 或 node server.js
|
||||
cp .env.example .env && vi .env
|
||||
# 3. 建置並啟動
|
||||
docker compose up -d --build
|
||||
# 4. 瀏覽器開 http://NAS-IP:8360
|
||||
# 4. 瀏覽器開 https://你的網域:8443(或區網 http://NAS-IP:8360)
|
||||
```
|
||||
|
||||
Synology Container Manager:「專案」→「新增」→ 選這個資料夾,它會讀取 `docker-compose.yml`,
|
||||
環境變數可在「環境」分頁填(等同 `.env`)。
|
||||
|
||||
### SSL
|
||||
|
||||
TLS 由 `360-player-web`(nginx)終結,Node 那端維持純 HTTP,所以應用程式碼裡沒有任何憑證邏輯。
|
||||
憑證放在 `SSL_CERT_DIR`(預設 `/volume1/docker/certs`,和其他專案共用同一份),
|
||||
唯讀掛進 nginx 容器:
|
||||
|
||||
```
|
||||
cert.pem 伺服器憑證
|
||||
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 和到期日。
|
||||
|
||||
兩點注意:
|
||||
|
||||
- 憑證是綁網域的,**用區網 IP 連 HTTPS 一定會跳警告**。所以 compose 同時保留了
|
||||
HTTP 的 `${PORT}:8360`:區網走 http、外網走 https。想只留 HTTPS 就把那段 `ports` 刪掉。
|
||||
- nginx 這邊關掉了 `proxy_buffering` 並把 `proxy_max_temp_file_size` 設為 0。
|
||||
影片是靠 HTTP Range 串流的,開著緩衝會讓 nginx 先把整段回應寫成暫存檔再吐出去,
|
||||
幾 GB 的來源會直接塞爆容器磁碟。`/api/events`(SSE)另外把 `proxy_read_timeout`
|
||||
拉到 24 小時,否則轉檔跑到一半進度就不再更新。
|
||||
|
||||
**NAS 上轉檔速度**
|
||||
- 沒有 GPU 時走 `libx264`(CPU)。NAS 的 CPU 把 4K 360 影片同時轉成三種畫質大約只有個位數 fps,
|
||||
82 分鐘的影片可能要跑 5–10 小時(可在背景跑,轉檔面板會顯示剩餘時間)。
|
||||
CPU 很弱可把 `.env` 的 `X264_PRESET` 改成 `superfast` 或 `ultrafast`。
|
||||
- NAS 有 Intel 內顯時,把 `docker-compose.yml` 裡 `devices: /dev/dri` 兩行取消註解,
|
||||
啟動時會自動偵測並改用 VAAPI 硬體編碼(此路徑尚未在實機驗證,偵測失敗會自動退回 CPU)。
|
||||
- NAS 有 Intel 內顯時,`docker-compose.yml` 裡的 `devices: /dev/dri` 要打開,
|
||||
啟動時會自動偵測並改用 VAAPI 硬體編碼,偵測失敗則自動退回 CPU。
|
||||
已在 Celeron J4025(UHD Graphics 600)+ DSM 實機驗證:容器啟動 log 顯示
|
||||
`轉檔引擎:VAAPI 硬體編碼`,`/api/config` 回傳 `modes: ["vaapi","cpu"]`。
|
||||
注意 DSM 內建的 ffmpeg 拿掉了 VAAPI 編碼器,**直接在 NAS 上 `npm start` 只會走 CPU**,
|
||||
硬體轉檔只有在容器裡才有。
|
||||
- **最快的做法**:在有 NVIDIA 顯卡的電腦上先跑 `npm start` 轉完,再把 `cache/` 裡的
|
||||
`*.mp4` 與 `*.mp4.json` 複製到 NAS 的 `CACHE_DIR`。快取檔只認來源檔的大小與修改時間(容許 2 秒誤差),
|
||||
所以兩邊看到同一個檔案就能直接共用。
|
||||
@@ -83,7 +115,7 @@ Synology Container Manager:「專案」→「新增」→ 選這個資料夾
|
||||
{
|
||||
"port": 8360,
|
||||
"host": "0.0.0.0",
|
||||
"videoDir": "W:/photo/Badminton",
|
||||
"videoDir": "/volume1/photo/Badminton",
|
||||
"cacheDir": "./cache",
|
||||
"extensions": [".mp4", ".mov", ".m4v", ".webm"],
|
||||
"qualities": [
|
||||
@@ -119,5 +151,6 @@ 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)
|
||||
```
|
||||
|
||||
@@ -7,7 +7,11 @@ services:
|
||||
image: 360-player:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 區網用 IP 直連的 HTTP(憑證是網域名,IP 連一定會跳警告,所以留一條明文)。
|
||||
# 想只走 HTTPS 的話把這兩行刪掉,改用下面的 expose 就好。
|
||||
- "${PORT:-8360}:8360"
|
||||
expose:
|
||||
- "8360"
|
||||
volumes:
|
||||
# 影片資料夾(NAS 上的實際路徑)→ 容器內 /videos,唯讀
|
||||
- "${VIDEO_DIR:-/volume1/photo/Badminton}:/videos:ro"
|
||||
@@ -21,3 +25,28 @@ services:
|
||||
# 換到沒有 /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"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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"]
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/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}"
|
||||
Reference in New Issue
Block a user