卡片:CM-2498(母卡 CM-2497)。只讀不寫程式碼。 實測環境:本機 docker(
chrislusf/seaweedfs:3.99、redis:7-alpine),自簽 CA 照安裝器同款參數(pathlen:0),終端憑證 SAN=DNS:guidant-<服務>,DNS:localhost,IP:127.0.0.1;Python 端用 BE.venv(minio 7.2.20、urllib3 2.8.0、redis 5.2.1、Flask-SocketIO 5.6.1、python-socketio 5.16.4、eventlet 0.40.4)。未碰任何遠端環境。
四件印象中三件對、一件要補:參數名、minio 吃 CA 的方式、eventlet+rediss 都可行;但有三個「不改會靜默壞掉」的陷阱(SSL_CERT_FILE 會整份取代公開 CA、redis-py 預設不驗 hostname、SocketIO 缺 CA 時服務照起但推播永遠收不到),以及一個母卡沒列到的缺口——SeaweedFS 的 filer 8888 在 docker 內網是明文、免帳密可直接讀檔,只把 S3 加密等於沒關門。
查了什麼:docker run chrislusf/seaweedfs:3.99 server -h,再起一顆帶自簽憑證的實連。
看到什麼
server 模式,前綴 -s3.):
-s3.cert.file、-s3.key.file——首腦印象正確。-s3.cacert.file+-s3.tlsVerifyClientCert——要求 client 憑證用(mTLS),本 FR 不需要。-s3.port.https——另開一個 HTTPS 埠、原 -s3.port 保持明文(雙軌)。-s3.port.https → -s3.port=8333 直接變純 HTTPS。log:Start Seaweed S3 API Server ... at https port 8333。對它送明文回 400 Client sent an HTTP request to an HTTPS server。這正是 D2 要的「關明文」形態。-s3.port.https=8443 → log 出現兩行(https 8443+http 8333),明文仍在。不要用這個旗標。wget(無 curl、無 openssl)。
wget -q -O /dev/null http://127.0.0.1:8333/healthz → 失敗(400)。現行 compose 健康檢查會變紅。wget -q --no-check-certificate -O /dev/null https://127.0.0.1:8333/healthz → 成功。SSL_CERT_FILE=<ca.crt> 給 wget(實測成功),但健康檢查只需判「活著」,--no-check-certificate 已足夠且不必再掛 CA 進這顆容器。weed shell 建 bucket(安裝器 install.sh:1875)與 weed filer.copy ... http://localhost:8888/...(install.sh:1922):完全不受影響——它們走 master 9333/filer 8888(HTTP+gRPC),不經 S3 埠。實測建 bucket、列 bucket、filer.copy 都成功。🔴 母卡沒列的缺口:filer/master/volume 埠在內網明文且免帳密
server 模式預設把 master 9333、filer 8888、volume 8080(及其 gRPC 1xxxx)都聽在容器 IP 上。從同網段另一顆容器實測:
wget http://guidant-seaweedfs:8888/buckets/testbkt/a.txt → 直接讀到檔案內容(無帳密)
wget http://guidant-seaweedfs:9333/cluster/status → 成功
wget http://guidant-seaweedfs:8080/status → 成功
production compose 的 default 網段上有 api/socketio/worker/fe/db-init 全部成員,任何一顆被打穿都能繞過 S3 的 TLS 與帳密直接讀寫全部客戶檔案。S3 埠加密若不同時處理這三個埠,#124 的威脅只修了一半。
可行解法(已實測):加 -ip=127.0.0.1 -ip.bind=127.0.0.1 -s3.ip.bind=0.0.0.0。結果:
Connection refused;weed shell(建 bucket)、weed filer.copy(灌 storage-seed)、健康檢查都照常——因為安裝器本來就是 docker exec 進同一顆容器執行。對 ② 的結論:compose 加 -s3.cert.file/-s3.key.file(不加 -s3.port.https)+-ip=127.0.0.1 -ip.bind=127.0.0.1 -s3.ip.bind=0.0.0.0;健康檢查改 https://127.0.0.1:8333/healthz --no-check-certificate;安裝器建 bucket/灌檔兩段不用改。
查了什麼:jedi-file-upload 原始碼、.venv 內 minio 7.2.20 原始碼,再在 docker 網路內實連 TLS SeaweedFS。
看到什麼
SDK:minio(不是 boto3)。jedi_file_upload/infra/adapter/minio/minio_adapter.py:59-64:
Minio(config.endpoint, access_key=..., secret_key=..., secure=config.secure)SeaweedFS adapter(seaweedfs_adapter.py:6)只是繼承它,同一條路。沒傳 http_client,所以套件目前沒有任何傳 CA 的參數入口。
minio 7.2.20 api.py:168-174:沒給 http_client 時自建 urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=os.environ.get('SSL_CERT_FILE') or certifi.where())——唯一不改套件的入口是 env SSL_CERT_FILE。
實測(容器內、endpoint guidant-seaweedfs:8333):
| 情境 | 結果 |
|---|---|
secure=False(今天 DB 裡的值)連 TLS 埠 |
InvalidResponseError ... 400 Client sent an HTTP request to an HTTPS server |
secure=True、不設 SSL_CERT_FILE |
SSLCertVerificationError(自簽 CA 不在 certifi) |
secure=True、SSL_CERT_FILE=ca.crt |
成功(put_object、list_buckets) |
| 同上但 endpoint 用 IP(不在 SAN) | SSLCertVerificationError——hostname 驗證有作用,SAN 寫 guidant-seaweedfs 正確 |
🔴 陷阱:SSL_CERT_FILE 是「取代」不是「追加」
同一行程設 SSL_CERT_FILE=<只有內部 CA> 後,實測:
| 客戶端 | 打 https://pypi.org |
|---|---|
| httpx 0.28(openai/anthropic SDK、AI 閘道走這條) | 失敗 |
Python 標準庫 ssl/urllib |
失敗 |
| requests | 成功(它看 REQUESTS_CA_BUNDLE,不看這個) |
也就是說 api/worker 設了它,AI 閘道打外部模型、寄信、Drive 等所有對外 HTTPS 會全斷。改成「certifi 內容+內部 CA」合併檔後,httpx、urllib 打公開站與打 SeaweedFS 都成功(已實測)。
對 ② 的結論:兩條路擇一——
SSL_CERT_FILE 指它。陷阱是 certifi 路徑在 Nuitka 編譯產物內要實查,且合併檔要在 CA 輪換時重產。MinioUploadConfigDTO 加 ca_cert_file,adapter 有值時自建 urllib3.PoolManager(ca_certs=...) 傳 http_client。影響只在主系統這條、不污染行程全域,較乾淨;但屬 jedi 套件異動,要決策者點頭+發版。agent 側(③)用同一個套件嗎要另查——agent 若自己包 minio 就各自處理。另外 managed_file_upload_service.py:109/123 的 secure 缺值預設 false 與安裝器三處 secure: false(母卡已列)都要翻;DB 裡舊值不翻,升級後第一個上傳就是上表第一列的錯誤。
查了什麼:BE 全 repo(排除 .venv/test/docs)+ jedi-python-package 全部套件(排除 archive/tests/dist)grep redis|Redis(|from_url|StrictRedis|REDIS_*|FlaskRedis|message_queue,逐筆開檔確認;並比對 .venv 內 jedi-iam 1.4.8 與源碼一致(diff 無差)。
結果:真正「建連線」的只有三條,其餘都是這三條的呼叫者或純註解。
| # | 位置 | 建法 | 吃哪個設定 | TLS 入口 |
|---|---|---|---|---|
| 1 | core/extensions.py:30 FlaskRedis(),core/app_factory.py:178 init_app(app, decode_responses=True) |
redis.Redis.from_url(app.config["REDIS_URL"]) |
config/config.py:152 REDIS_URL——寫死 redis://、不看 REDIS_SSL |
只能靠 URL:改 rediss://...?ssl_ca_certs=...&ssl_check_hostname=true |
| 2 | core/app_factory.py:180-195 SocketIO message_queue |
python-socketio RedisManager 吃 URL |
REDIS_USER/REDIS_SECRET/REDIS_HOST、REDIS_SSL 切 rediss |
同上,URL query;目前沒帶 CA 參數 |
| 3 | jedi-iam jedi_iam/common/utils/redis_client_util.py:10-50 RedisClient() |
redis.Redis(host=, username=, password=, ssl=, ...),**port 不帶(固定 6379) |
經 get_setting() → 主專案 common/iam_ports.py EnvSettingsReader(os.getenv,REDIS_SECRET 另組 JSON) |
已完備**:REDIS_SSL=true 時強制 ssl_cert_reqs=required+ssl_check_hostname=True,REDIS_SSL_CA_CERTS 有值就帶 ssl_ca_certs |
#3 的呼叫者(每次操作 new 一顆 RedisClient):
mfa/app/service/email_service.py:59,93、mfa/infra/email/adapter/email_adapter.py:9、mfa/app/service/attempt_guard.py:36——這就是 compose 注解說「MFA 套件只讀 REDIS_SECRET」那條。common/util/redis_rate_limiter.py:25(API 節流+AI 閘道每分鐘上限,core/plugins/ai_gateway.py:171)、common/middleware/socketio_limits.py:44(socketio 同帳號連線數)、core/plugins/ai_bot.py:37(聊天紀錄)、infra/survey/adapters.py:29(問卷共編線上名單)。#1 的呼叫者:app/cloud_integration/service/google_drive_integration_service.py:117,160,163(Drive OAuth state)。
其他 jedi 套件(survey/ai-bot/detection/ai-gateway)只有註解或 port 介面提到 Redis,不自建連線;jedi-common 有一支 jedi_common/session/redis/redis.py 的 FlaskRedis() 實例,主專案與各套件都沒 import 它,等同死碼。
實測(BE .venv → 本機 TLS-only Redis,--port 0 --tls-port 6379):
| 情境 | 結果 |
|---|---|
from_url("rediss://...?ssl_ca_certs=<ca>") |
成功 |
from_url("rediss://...") 不帶 CA |
CERTIFICATE_VERIFY_FAILED |
from_url("redis://...") 打 TLS 埠 |
Connection closed by server(D2 關明文生效) |
FlaskRedis+REDIS_URL=rediss://...?ssl_ca_certs= |
成功(set/get) |
jedi-iam RedisClient(REDIS_SSL=true+REDIS_SSL_CA_CERTS) |
成功 |
🔴 陷阱:redis-py 5.2.1 rediss:// 預設不驗 hostname
docker 網路內用容器 IP(不在 SAN)連:只帶 ssl_ca_certs → 成功;加 &ssl_check_hostname=true → 失敗。也就是 #1、#2 走 URL 時必須明帶 ssl_check_hostname=true,否則只驗「是這把 CA 簽的」、不驗「是 guidant-redis」。#3 套件端已自帶,不受影響。
對 ② 的結論:
REDIS_SSL_CA_CERTS(新增 env 前必查既有的鐵則),BE config.py 讀同一個名字組 URL,不另創。config.py:152 依 REDIS_SSL 切 scheme 並補 ?ssl_ca_certs=<quote 過的路徑>&ssl_check_hostname=true;app_factory.py:186-187 同樣補 query,並改用 quote_plus 包密碼(現行 socketio 那條沒 quote,與 config.py:152 不一致——密碼含 @/:// 會組壞 URL,安裝器若只產英數字則暫無症狀)。REDIS_SSL=true+REDIS_SSL_CA_CERTS=<容器內 CA 路徑>+掛 CA 檔。redis-cli --tls --cacert <ca>(image 內 redis-cli 支援 --tls,已實測)。Redis 服務那顆要掛自己的 cert/key/ca。rediss:// 訊息佇列查了什麼:本機起 TLS-only Redis(含照 compose 的 guidant-socketio ACL:resetchannels "&flask-socketio" -@all +@connection +publish +subscribe +unsubscribe),寫一支最小 Flask-SocketIO server(eventlet.monkey_patch()、async_mode="eventlet"、message_queue=rediss://guidant-socketio:...@localhost:16379/0?ssl_ca_certs=<ca>&ssl_check_hostname=true),client 連上 namespace 後,另起一個獨立行程(模擬 api/worker)只透過 SocketIO(message_queue=url).emit(...) 推播。
看到什麼
| 情境 | 結果 |
|---|---|
| 不帶 message_queue(對照組),外部行程 emit | client 收到 [](預期:沒佇列就收不到) |
帶 rediss message_queue+CA+hostname 檢查 |
server log redis backend initialized.,client 收到 [{'x': 1}] |
帶 rediss 但不帶 CA |
server 照常起、HTTP 握手正常,log 只有 Cannot receive from redis... retrying in 1 secs 無限重試,推播全掉 |
eventlet 0.40.4 的 ssl monkey patch 與 redis-py 5.2.1 TLS 在此組合下沒撞到歷史坑;最小 ACL 帳號也夠用。
🔴 陷阱:缺 CA(或 CA 路徑錯、檔案沒掛進容器)時,socketio 容器健康檢查是綠的、前端連得上,只是「別的行程推過來的訊息」永遠到不了——症狀會被誤判成前端或業務邏輯問題。
對 ② 的結論:可行,照 C 的 URL 組法即可。驗收時一定要做跨行程推播(例如觸發一個由 api/worker 推給前端的事件),只看 socketio 容器起得來不算數;並 grep socketio log 確認沒有 Cannot receive from redis。
打折說明:本測是用最小 server 驗「eventlet+rediss+ACL」這個組合,沒有跑 RUN_MODE=socketio python main.py——DEV .env 的 Redis 指向 189 的明文 Redis,要跑完整 main.py 得改 .env 指本機 TLS Redis 並帶齊 DB 等設定,超出只讀範圍。套件版本與 BE 完全相同,風險在「main.py 有沒有在別處另建連線」,C 段已逐檔確認沒有。② 在 188 實裝時補跑一次完整服務即可覆蓋。
main.py:152 三種模式都跑 create_app(),core/app_factory.py:178 的 redis_client.init_app(...) 不分模式一律執行(#1);socketio 的 #2 只在 enable_socketio(socketio 模式)才建。worker 實際打 Redis 的是:
RedisClient:AI 閘道每分鐘上限(redis_rate_limiter)、AI 聊天紀錄(core/plugins/ai_bot.py);from_url 是 lazy,不打不連),worker 目前沒有呼叫者,但設定錯了一樣是潛在炸點。app/background_job/ 內沒有任何 Redis 呼叫——撿單走 DB。
對 ② 的結論:worker 不用特別處理,跟 api 一樣設 REDIS_SSL+REDIS_SSL_CA_CERTS+掛 CA 即兩條都切到。驗收:worker 容器內觸發一次 AI 呼叫(會打節流計數),log 無 Redis 連線錯誤。
憑證
install.sh:1632 起),簽兩張終端憑證:SAN DNS:guidant-seaweedfs,DNS:localhost,IP:127.0.0.1/DNS:guidant-redis,DNS:localhost,IP:127.0.0.1,EKU serverAuth。注意該段簽完會 rm ca.srl,新簽要再帶 -CAcreateserial。SeaweedFS(compose guidant-seaweedfs) 3. command 加 -s3.cert.file/-s3.key.file;不要加 -s3.port.https(會留明文雙軌)。 4. command 加 -ip=127.0.0.1 -ip.bind=127.0.0.1 -s3.ip.bind=0.0.0.0,把 filer 8888/master 9333/volume 8080 收回容器本機(見 A 段缺口;建議與決策者確認後併入本棒)。 5. 健康檢查改 wget -q --no-check-certificate -O /dev/null https://127.0.0.1:8333/healthz。 6. 安裝器 weed shell 建 bucket、weed filer.copy 灌檔兩段不用改(已實測)。
S3 客戶端(api/socketio/worker) 7. 選 B 段兩條路之一。若走 SSL_CERT_FILE:必須是 certifi+內部 CA 合併檔,否則 AI 閘道與所有對外 HTTPS 斷線。 8. DB system_configs STORAGE_CONFIG secure 翻 true(安裝器三處+升級腳本+managed_file_upload_service.py:109/123 預設值)。
Redis 9. compose guidant-redis:--port 0 --tls-port 6379 --tls-cert-file --tls-key-file --tls-ca-cert-file --tls-auth-clients no;健康檢查改 redis-cli --tls --cacert ...。 10. config.py:152 依 REDIS_SSL 切 scheme,補 ssl_ca_certs+ssl_check_hostname=true;app_factory.py:186-187 同步並改用 quote_plus。 11. env 名沿用 REDIS_SSL+REDIS_SSL_CA_CERTS(jedi-iam 已在讀),三個 BE 容器都設;.env sample 與 deployment-env.md 補 REDIS_SSL_CA_CERTS 一行。 12. jedi-iam RedisClient 不用改。
驗收必做(只看容器起得來不算) 13. 上傳+下載一個檔案(S3 TLS);AI 呼叫一次(certifi 合併檔沒弄壞對外 HTTPS);MFA 寄 Email OTP 一次(#3);Drive 授權一次(#1);跨行程 socketio 推播一次(#2)。 14. grep 三容器 log:無 CERTIFICATE_VERIFY_FAILED、Cannot receive from redis、HTTP request to an HTTPS server。 15. 從 fe 容器 wget http://guidant-seaweedfs:8888/、redis-cli -h guidant-redis ping(明文)都要失敗。 16. 舊庫升級排練:升完 secure 已翻 true、既有檔案讀得到。
agent 側(③,給 CM-2489 參考) 17. agent compose :306-314 的 init 容器是另一顆容器用 weed shell -master=guidant-agent-seaweedfs:9333 連——若 agent 那顆也照第 4 點收 bind,這段會連不上,要改成 docker exec 進同一顆或保留 master 對內網開放。