新增 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:
2026-08-21 11:07:29 +08:00
co-authored by Claude Opus 5
parent 2a9e59357e
commit fa220b00eb
5 changed files with 221 additions and 11 deletions
+124
View File
@@ -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}"