# 自選 Relay（Floating Relay / Leader Election）設計文件

- 日期：2026-06-15
- 狀態：設計提案（**尚未動程式碼**，待確認）
- 目標專案：`E:\ConvertFW\SOCKET`

---

## 1. 訴求

> 「只要有一台 Agent 活著，這個站就有 Relay。讓 Agent 之間彼此選出一台當 Relay_Server。」

- 取消「常駐、單獨佈署」的中央 Relay 當站內單點故障源。
- 同站（同 KMSN／同區網）的 Agent 自行選出一台當 Relay；它死了，剩下的自動重選。
- 站內流量留在區網：**降延遲、省雲端頻寬、減少雲端連線數、Relay 掛了站內仍通**。

### 範圍界定
- **本期（Phase 1）：站內 floating relay。** 同一客戶站（同 NAT 後面）自選自癒。
- **遠端／跨站維持現有雲端 Relay 不變**（混合模式）。遠端 Commander、手機看 WebScreen 仍走雲端入口，不受本期影響。
- 對外入口要固定到 SERVER 的強化，列為 Phase 3 選配。

---

## 2. 現況（grounded in code）

| 元件 | 連線模型 | 關鍵檔 |
|---|---|---|
| Agent | `Main()` 讀 `RELAY_HOST`（stk.ini）→ `RelayListenLoop()` 主動連出 9530 + 備援 + `127.0.0.1` fallback | `StkAgent/source/stk_agent.prg`、`shared/relay_client.prg` |
| Relay | `relay_server.exe`（PyInstaller，A/B 版），`start_relay.bat`/`watchdog_relay.bat` 啟動；`RELAY_PORT=9530`，9531=H.264 passthrough | `Relay/relay_server.py`、`Relay/*.bat` |
| 路由 | 星狀：全部繞中央 Relay；`forward_to_agents`（KMSN:STATION 配對）、`forward_to_commanders` | `relay_server.py` |
| 共用 | 同站 Agent 共掛 `W:\`，已用檔案旗標（`SHUTDOWN.FLG/ACK`）；Agent 啟動會自建 W: 映射並 mirror `W:\socket`→`C:\STK\socket` | `stk_agent.prg` |

關鍵可複用資產：
- Agent **已會 listen（UDP）也會撥出**。
- `relay_client.prg` 的 length-prefixed JSON 收送/註冊/keepalive 協定，**peer 連線可照抄**。
- `relay_server.exe` 已是穩定、可佈署的成品 —— 選上的 Agent**直接 spawn 它**即可，不重寫。

---

## 3. 架構：W:\ 租約檔選舉（Phase 1 採用）

### 3.1 為何用 W:\ 租約檔（方案 A）
- 同站 Agent 本來就共掛 W:\，且系統重度使用檔案旗標（SHUTDOWN.FLG/ACK）→ 延伸最自然、零選舉協定碼、最 robust。
- 依賴 W:\ 檔案伺服器：本系統 W:\ 就是資料本體，W:\ 掛了整套程式本來就不能跑 → 不算新增風險。
- UDP 廣播選舉（方案 B）列為 Phase 2：穩定後加，用來「更快偵測 leader 死亡」，非必要。

### 3.2 租約檔格式
`W:\socket\relay_leader.json`（與既有 `W:\socket` 同目錄）

```json
{
  "kmsn":        "9999-9999-99-99",
  "station":     "S001",
  "ip":          "192.168.1.10",
  "port":        9530,
  "pid":         12345,
  "epoch":       7,
  "lease_until": "20260615203015"
}
```
- `ip`：leader 的**區網 IP**（同站 client 直連用）。
- `epoch`：選舉世代序號，每次重選 +1，client 用來判斷 leader 是否換人 → 觸發重連。
- `lease_until`：租約到期時間（YYYYMMDDHHMMSS）。leader 每 `LEASE_RENEW`(預設 3s) 續租，租約長度 `LEASE_TTL`(預設 10s)。

### 3.3 啟動流程（每台 Agent，插在讀 RELAY_HOST 之前）
```
RelayBootstrap():
  loop:
    L := ReadLeaderLease()                       // 讀 W:\socket\relay_leader.json
    IF L 有效(lease 未過期) AND Ping(L.ip:port) 成功:
        s_cRelayHost := L.ip                      // 當 client 連現任 leader
        s_nLeaderEpoch := L.epoch
        RETURN CLIENT
    ELSE:
        // 無 leader 或 leader 死 → 嘗試搶租約
        IF AcquireLeaderLease():                  // 獨占建檔/rename，原子搶鎖
            SpawnLocalRelay()                     // 啟動本機 relay_server.exe
            s_cRelayHost := "127.0.0.1"
            StartLeaseRenew()                      // 背景續租心跳
            RETURN LEADER
        ELSE:
            sleep(隨機 0.2~1.0s)                   // 搶輸了 → 退讓重讀(別人剛當選)
            continue
```

### 3.4 原子搶租約（AcquireLeaderLease）
- 用**獨占建立**鎖檔 `W:\socket\relay_leader.lock`（`FCreate` 失敗=別人持有；成功=我贏），與 SHUTDOWN.FLG 同招。
- 贏家：寫 `relay_leader.json`（epoch+1）、刪 lock 或轉成續租。
- 防驚群：搶輸者隨機退讓 0.2~1.0s 再重讀（多半已看到新 leader）。

### 3.5 續租與故障轉移
- **Leader**：背景每 `LEASE_RENEW` 更新 `lease_until`；同時確認本機 relay_server.exe 還活著（死了就重拉）。
- **被換掉/網路斷**：leader 偵測到自己無法續租（W:\ 不通）→ 主動停掉本機 relay、降為 client。
- **Client**：每 `POLL`(預設 2s) 讀租約：
  - `epoch` 變了 → leader 換人 → 斷線重連新 `ip`。
  - 租約過期且 ping 不到 → 進入搶租約流程（自己可能當選）。
- **故障轉移時間** ≈ `LEASE_TTL`(10s) + 搶租約(1s) + 重連 → 約 10~12s。要更快 → Phase 2 UDP 廣播即時宣告死亡。

### 3.6 內嵌 Relay（SpawnLocalRelay）
- 每台 Agent 都先佈署 `relay_server.exe`（沿用現有 A/B + `start_relay.bat`）。
- 平常不跑；**選上才啟動、被換掉才停**。完全複用已驗證的 Relay 邏輯。
- Agent 自己連 `127.0.0.1:9530`（fallback 路徑現成）。

---

## 4. 對外（遠端）—— 本期維持現狀

- 站內 client（其他 Agent、站內 Commander）：走 floating relay（區網 IP）。
- 遠端 Commander、手機 WebScreen：**仍連雲端 Relay**（DDNS/固定入口不變）。
- 雲端 Relay 與站內 floating relay 可並存（Agent 可同時掛兩邊，或雲端 Relay 轉發到站內 leader）。**本期不拆雲端，零回歸風險。**
- Phase 3 選配：把對外入口釘在有 port-forward 的 SERVER（只有它能當「對外 leader」），讓遠端也享受自癒。

---

## 5. 安全

- 直連/選舉都要驗 `kmsn` + 一把**站內共用 token**（放 stk.ini，如 `RELAY_TOKEN`），避免區網任何主機偽裝 Agent 或搶當 leader。
- `relay_server.py` 既有 `MAGIC` + KMSN 配對保留；peer 握手沿用同一套 + token 檢查。
- `relay_leader.json` 內不放敏感資訊（只有 IP/port/epoch）。

---

## 6. 各元件改動清單

| 檔 | 改動 | 規模 |
|---|---|---|
| `stk_agent.prg` | 新增 `RelayBootstrap()`/`AcquireLeaderLease()`/`ReadLeaderLease()`/`StartLeaseRenew()`/`SpawnLocalRelay()`；`Main()` 在讀 RELAY_HOST 後、`RelayListenLoop()` 前插入 bootstrap；client 端輪詢 epoch 重連 | 中 |
| `relay_client.prg` | `RelayConnect()` 外層加「優先直連 leader.ip、失敗 fallback」 | 小 |
| `relay_server.py` | （Phase 1 幾乎不動）可加：leader 啟動時把自身 ip 寫回租約由 Agent 處理，Relay 本身不需知道選舉 | 微 |
| stk.ini | 新增 `RELAY_TOKEN`、`LEASE_TTL`、`LEASE_RENEW`、`LEASE_POLL`（含預設保護）；同步 INI_EDITOR 模板 | 小 |
| 佈署 | `relay_server.exe` 隨 Agent 佈到每台（stk_uploader 群組） | 小 |

---

## 7. 分期

- **Phase 1**：W:\ 租約選舉 + 內嵌 relay + client 重連 + token。站內自選自癒，遠端維持雲端。← 本次要做
- **Phase 2**：UDP 廣播加速死亡偵測（縮短故障轉移到 ~2s）、split-brain 防護。
- **Phase 3**：對外入口釘 SERVER／雲端 Relay 退化為純跨站 rendezvous。

---

## 8. 風險與邊界

| 風險 | 說明 | 對策 |
|---|---|---|
| W:\ 不通 | 租約讀寫失敗 | W:\ 即資料本體，本來就不可用；Agent 已自建 W: 映射 |
| 搶租約競態（驚群） | 多台同時看到無 leader | 獨占建檔原子鎖 + 隨機退讓 |
| split-brain | 網路分區致兩個 leader | Phase 1：epoch 單調遞增 + W:\ 單一真相；Phase 2 UDP 補強 |
| 故障轉移空窗 | TTL 內封包無人轉發 | client 重試/退避；可調小 TTL（代價：續租更頻繁） |
| 遠端漂移 | leader 換機致對外 IP 跑掉 | 本期遠端不走 floating（維持雲端）；Phase 3 釘 SERVER |

---

## 9. 待決問題（請確認）

1. `LEASE_TTL` / `LEASE_RENEW` 預設值（暫定 10s / 3s）—— 接受？故障轉移約 10~12s。
2. 站內 token 要不要做（建議要；不做則任何區網主機可搶當 leader）。
3. `relay_leader.json` 放 `W:\socket\` 可以嗎（與既有 socket 檔同目錄）。
4. 內嵌 relay 用 spawn `relay_server.exe`（推薦、複用）vs 把轉發邏輯改寫進 Harbour Agent（去 Python 依賴、工程量大）—— 確認用前者？
