# FR-130.1 探脈報告——SeaweedFS／Redis 走 TLS 的實測與連線點清單

> 卡片：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 加密等於沒關門。

---

## A. SeaweedFS 3.99 的 S3 TLS

**查了什麼**：`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` 保持明文（雙軌）。
- 行為實測：
  - **只給 cert/key、不給 `-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），明文仍在。**不要用這個旗標。**
- 健康檢查：image 內只有 busybox `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` → 成功。
  - 想驗憑證可設 env `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`。結果：

- 容器內只剩 S3 8333 聽在全部介面，其餘全部只聽 127.0.0.1；
- 同網段容器打 8888／9333 → `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／灌檔兩段不用改。

---

## B. 主系統 S3 客戶端怎麼餵 CA

**查了什麼**：`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`：
  ```python
  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 都成功（已實測）。

**對 ② 的結論**：兩條路擇一——
1. **不動套件（建議先走）**：image build 期或容器啟動時產「certifi bundle＋內部 CA」合併檔，`SSL_CERT_FILE` 指它。陷阱是 certifi 路徑在 Nuitka 編譯產物內要實查，且合併檔要在 CA 輪換時重產。
2. **動套件**：`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 裡舊值不翻，升級後第一個上傳就是上表第一列的錯誤。

---

## C. Redis 連線點全清單

**查了什麼**：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`）：

- jedi-iam MFA：`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 套件端已自帶，不受影響。

**對 ② 的結論**：
- CA 路徑的 env 名**沿用 jedi-iam 已有的 `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，安裝器若只產英數字則暫無症狀）。
- 三個 BE 容器（api／socketio／worker）都要 `REDIS_SSL=true`＋`REDIS_SSL_CA_CERTS=<容器內 CA 路徑>`＋掛 CA 檔。
- Redis 自己的 compose 健康檢查要改 `redis-cli --tls --cacert <ca>`（image 內 redis-cli 支援 `--tls`，已實測）。Redis 服務那顆要掛自己的 cert／key／ca。

---

## D. eventlet＋`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 實裝時補跑一次完整服務即可覆蓋。

---

## E. worker 行程用哪條連線

`main.py:152` 三種模式都跑 `create_app()`，`core/app_factory.py:178` 的 `redis_client.init_app(...)` **不分模式一律執行**（#1）；socketio 的 #2 只在 `enable_socketio`（socketio 模式）才建。worker 實際打 Redis 的是：

- #3 `RedisClient`：AI 閘道每分鐘上限（`redis_rate_limiter`）、AI 聊天紀錄（`core/plugins/ai_bot.py`）；
- #1 FlaskRedis：連線物件會建（`from_url` 是 lazy，不打不連），worker 目前沒有呼叫者，但設定錯了一樣是潛在炸點。

`app/background_job/` 內沒有任何 Redis 呼叫——撿單走 DB。

**對 ② 的結論**：worker 不用特別處理，跟 api 一樣設 `REDIS_SSL`＋`REDIS_SSL_CA_CERTS`＋掛 CA 即兩條都切到。驗收：worker 容器內觸發一次 AI 呼叫（會打節流計數），log 無 Redis 連線錯誤。

---

## 給 ② 的開工清單

**憑證**
1. 安裝器沿用 agent CA（`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`。
2. 私鑰檔權限要讓 redis 與 seaweedfs 容器內的執行身分讀得到（本測用 644 跑通，實際 uid 未查，② 裝機時確認）；CA 私鑰**不**掛進任何容器。

**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 對內網開放。
