# LLM ゲートウェイ 実装計画書 v2

対象ドメイン: `https://ai.disk.mydns.jp`
上流: `http://192.168.0.13`（OpenAI 準拠 llama-server、複数モデル）
参照テンプレート: `https://github.com/dmajima/project-template`

v1 に対し、設計・セキュリティ・インフラの3レビューで挙がった指摘を反映した。
主要な変更点はセクション 12 に一覧化している。

## 1. 確定した前提

| 項目 | 決定 |
|---|---|
| 実行基盤 | Docker Engine + Compose を導入し、テンプレートの docker-compose 方式を踏襲 |
| リポジトリ構成 | 親 + gateway + admin の3リポジトリ。gateway / admin を submodule 化 |
| 上流モデル | 複数モデル。キーごとの許可モデル指定が実効性を持つ |
| TLS 終端 | 既存 Apache（`*:443`）。certbot 導入済み |

稼働環境の実測値は `workspace/env-facts.md` を参照。要点は次のとおり。

- ディスクは 98 GiB 中 85 GiB 空き。メモリ 7 GiB、8 vCPU
- UFW 有効。22、80、443 の tcp のみ許可。`DEFAULT_FORWARD_POLICY` は `DROP`
- nginx は実質未使用。SSE のフォールバック経路として転用可能
- Apache の `:443` vhost は現在ひとつだけで、既定 vhost を兼ねている

## 2. 全体構成

```mermaid
flowchart TB
    C["外部クライアント"] -->|"HTTPS<br/>Bearer 発行キー"| AP["Apache 443<br/>ai.disk.mydns.jp"]

    AP -->|"/admin/*<br/>IP 許可リストで事前遮断"| ADM["admin<br/>127.0.0.1:4000"]
    AP -->|"/* (catch-all)<br/>flushpackets=on"| GW["gateway public<br/>127.0.0.1:3000"]

    ADM -->|"control API<br/>X-Control-Token"| CTL["gateway control<br/>compose 内部のみ 3001"]
    CTL --- DB[("SQLite WAL<br/>named volume")]
    GW --- DB

    GW -->|"Bearer 内部キーに差し替え<br/>ボディはバイト列のまま"| UP["llama-server<br/>192.168.0.13:80"]
```

公開ポートはループバック限定。control ポートはホストに公開せず、Compose ネットワーク内のみで到達可能とする。

## 3. ルーティング設計

### 3.1 対応表

| 公開 URL | 転送先 | 備考 |
|---|---|---|
| `https://ai.disk.mydns.jp/admin/*` | `http://127.0.0.1:4000/admin/*` | 管理画面。パスは書き換えない |
| `https://ai.disk.mydns.jp/*` | `http://127.0.0.1:3000/*` → `http://192.168.0.13/*` | catch-all。パスをそのまま維持 |
| `http://ai.disk.mydns.jp/.well-known/acme-challenge/*` | ローカル webroot | 証明書更新用。リダイレクトより先に評価 |
| `http://ai.disk.mydns.jp/*` | HTTPS へ 301 | |

### 3.2 Apache 設定上の要点

- `ProxyPass` は記述順に先勝ちで評価されるため、`/admin` を catch-all の `/` より前に置く。
- 管理画面は FastAPI の `root_path=/admin` で `/admin` 配下に自力でマウントする。`ProxyPassReverse` によるパス書き換えを不要にし、リダイレクトと相対アセットの破綻を避ける。
- SSE の逐次配信には `flushpackets=on` が必須。`SetEnv` ではなく `ProxyPass` の引数として与える。
- Apache が既定で付与する転送元ヘッダを止め、ゲートウェイ側の制御に一本化する。
- 有効化するモジュールは `proxy` `proxy_http` `headers` `rewrite` `ssl` の5つ。`http2` は本用途で不要であり、新規ロードには Apache の再起動が必要で既存サイトに瞬断を与えるため、対象から外す。

```apache
ProxyAddHeaders Off
ProxyPreserveHost Off
ProxyTimeout 3600
SetEnv no-gzip 1
SetEnv proxy-sendchunked 1

Header always set Strict-Transport-Security "max-age=31536000"

<Location /admin>
    Require ip 127.0.0.1 <運用元の接続元レンジ>
</Location>

ProxyPass        /admin http://127.0.0.1:4000/admin flushpackets=on
ProxyPassReverse /admin http://127.0.0.1:4000/admin

ProxyPass        / http://127.0.0.1:3000/ flushpackets=on
ProxyPassReverse / http://127.0.0.1:3000/
```

### 3.3 公開パスの方針

v1 では全パスを無条件で上流へ流す設計としたが、これには利用者間の情報漏えいという重大な欠陥がある。llama.cpp のスロット照会エンドポイントは、そのとき処理中のプロンプトと生成中テキストを返す。上流は内部キー1本で全利用者分を処理しており利用者の区別を持たないため、誰か1人がこのパスを叩くと他人の入力内容が読める。

したがって次の方針に改める。

| 方針 | 内容 | 既定 |
|---|---|---|
| 拒否リスト | 管理系・観測系のパスを 404 で拒否する。`/slots` `/props` `/metrics` およびその配下 | 有効 |
| 厳格な許可リスト | 外部提供する必要最小パスのみを通し、他は 404 | 任意。`PATH_POLICY=allowlist` で切替 |

拒否リストを既定とすることで、指定いただいた全面転送の性質は保ちつつ、既知の漏えい経路だけを塞ぐ。より強い分離が必要なら許可リスト方式へ切り替える。

上流側でもスロット公開を無効にし、スロット保存パスを設定しない運用を併せて固定する。

`/admin` を管理画面に割り当てるため、上流の `/admin` 配下は本ドメインからは到達不能になる。llama-server は当該パスを使わないため実害はないが、仕様として明記する。

ゲートウェイ自身のヘルスチェックと管理 API はすべて control ポートに置き、公開ポートのパス名前空間には一切のエンドポイントを生やさない。

## 4. gateway の設計

### 4.1 無改変中継の実装方針

改変するのは次のヘッダのみとし、それ以外はボディを含めてバイト単位で維持する。

| 項目 | 扱い |
|---|---|
| `Authorization` | 発行キーから内部キーへ差し替え |
| `Host` | 上流ホストへ書き換え |
| hop-by-hop ヘッダ | RFC に従い除去。`Connection` `Keep-Alive` `TE` `Trailer` `Transfer-Encoding` `Upgrade` `Proxy-*` |
| `X-Forwarded-*` | クライアント由来の値は除去。上流へは既定で付与しない |
| その他のヘッダ | そのまま転送 |
| リクエストボディ | `bytes` のまま転送。再シリアライズしない |
| レスポンス | `aiter_raw()` で受け、自動解凍せずに素通し |

モデル許可判定のために JSON を読むが、読むのは検査用のコピーであり、上流へ送るのは受信した元のバイト列とする。

httpx の自動解凍を無効化し `aiter_raw()` を使うことで、上流が圧縮を返した場合もバイト一致を保つ。

### 4.2 アドミッションコントロール

無改変中継の原則は「転送するなら書き換えない」ことであり、転送前に受理を拒むことは原則に反しない。次の上限を設ける。

| 項目 | 内容 | 超過時 |
|---|---|---|
| `MAX_REQUEST_BODY_BYTES` | リクエストボディの上限 | 413 |
| `MAX_GRAMMAR_BYTES` | `grammar` 文字列長の上限 | 400 |
| `MAX_N_PROBS` | `n_probs` の上限 | 400 |
| `MAX_REQUEST_DURATION` | 1リクエストの最大所要時間 | 504 |
| `IDLE_TIMEOUT` | ストリーム中の無通信許容時間 | 504 |

これらは共有バックエンドを1利用者が枯渇させることを防ぐために必要である。値の書き換えは行わず、逸脱値は拒否する。

### 4.3 リクエスト処理フロー

```mermaid
flowchart TD
    A["リクエスト受信"] --> P{"拒否パス?"}
    P -->|"該当"| E404["404"]
    P -->|"否"| B{"Bearer あり?"}
    B -->|"なし"| E401["401 invalid_api_key"]
    B -->|"あり"| C["SHA-256 でキー検索"]
    C --> D{"有効かつ期限内?"}
    D -->|"否"| E401
    D -->|"可"| G1{"全キー横断の<br/>同時実行上限内?"}
    G1 -->|"超過"| E429["429 Retry-After"]
    G1 -->|"可"| F{"キー単位の<br/>同時実行上限内?"}
    F -->|"超過"| E429
    F -->|"可"| G{"RPM / RPD 上限内?"}
    G -->|"超過"| E429
    G -->|"可"| H{"モデル検査"}
    H -->|"許可外"| E403["403 model_not_allowed"]
    H -->|"検査不能かつ<br/>許可リストあり"| E403
    H -->|"可"| I["内部キーに差し替えて転送"]
    I --> J["レスポンスをストリーム返却"]
    J --> K["使用量を記録"]
```

### 4.4 モデル許可

v1 では検査できない場合に素通しする設計だったが、これでは `Content-Type` を変えるか本文を膨らませるだけで制限を回避できる。**検査不能時は拒否する**方式に改める。

| 条件 | 挙動 |
|---|---|
| キーの許可リストが空 | 無制限キーとして扱い、検査を省略 |
| 許可リストあり、かつモデル名を取得できた | 許可リストと照合 |
| 許可リストあり、かつ検査不能 | 403 で拒否 |

検査対象は `model` フィールドを持ちうる全エンドポイントとする。`/v1/chat/completions` `/v1/completions` `/v1/embeddings` `/v1/rerank` に加え、llama.cpp 独自の `/completion` `/infill` `/v1/completions` 系の別名も含める。旧来のパスを検査対象から漏らすと、そこが迂回路になる。

`/v1/models` のレスポンスは許可リストで絞り込む。これはレスポンス側の改変であるため `FILTER_MODELS_RESPONSE` で切替可能とし、既定は有効。

### 4.5 レート制限

| 制限軸 | 適用単位 | 実装 |
|---|---|---|
| 毎分リクエスト数 | キー | スライディングウィンドウ |
| 毎日リクエスト数 | キー | 日付キーのカウンタ。境界は JST |
| 同時実行数 | キー | asyncio.Semaphore |
| 同時実行数 | **全キー横断** | asyncio.Semaphore。上流の並列スロット数以下に設定 |
| 毎分トークン数 | キー | 非ストリーミング応答の `usage` から集計。ベストエフォート |

全キー横断の上限は必須である。キー単位の制限だけでは、1本の有効キーで長時間の生成を並べて上流のスロットを占有し、他の全利用者を停止させられる。

カウンタはプロセスメモリに持ち、周期的に SQLite へスナップショットする。プロセス異常終了による制限のリセットを悪用されないためである。

超過時は 429 と `Retry-After` を返す。エラー本文は OpenAI 互換の形とし、上流到達不能時の 502、504 も同一のエラーエンベロープで統一する。

### 4.6 トークン計測と無改変の両立

ストリーミング応答から消費トークンを正確に取るには項目の注入が必要で、これはリクエスト改変にあたる。注入は行わない。

- 非ストリーミング応答は `usage` をそのまま読んで記録する
- ストリーミング応答はクライアントが自ら指定した場合のみ記録する
- 記録できない場合はリクエスト件数のみを課金軸とする

### 4.7 キーの形式と保管

- 形式: `sk-aig-` + 英数 32 文字。生成には `secrets` モジュールを使う
- 保管: SHA-256 ハッシュのみ。平文は保存しない
- 表示: 先頭 12 文字のプレフィックスのみ。発行直後の画面でだけ平文を一度表示する
- 失効: 無効化フラグと有効期限の両方を持つ

高エントロピーな機械生成トークンであるため、ソルトやストレッチは不要。データベース単体の漏えい耐性を上げたい場合はサーバー側ペッパーによる鍵付きハッシュを任意で選べる。

### 4.8 内部キーの漏えい防止

- 上流呼び出しの例外処理で `Authorization` を必ずマスクしてからログへ出す
- エラー応答本文が小さい場合に限り、内部キー文字列の混入を走査して置換する軽量な安全網を設ける。ストリーミング本体には適用しない
- 受入試験による確認だけに頼らず、常時稼働の防御として実装する

### 4.9 データモデル

| テーブル | 主な列 |
|---|---|
| `api_keys` | id, name, key_hash, key_prefix, enabled, created_at, expires_at, note |
| `api_key_limits` | key_id, rpm, rpd, concurrency, tpm |
| `api_key_models` | key_id, model |
| `request_logs` | id, key_id, ts, method, path, model, status, duration_ms, prompt_tokens, completion_tokens, client_ip |
| `admin_audit_log` | id, ts, actor, action, target_id, before, after, source_ip |
| `rate_snapshots` | key_id, window, count, updated_at |
| `settings` | k, v |

管理者アカウントはテーブルを持たない。単一管理者とし、環境変数の値で認証する。これにより「管理画面はデータストアを直接触らない」という規約と整合する。

索引は次を張る。`api_keys.key_hash` に一意索引、`request_logs(key_id, ts)` と `request_logs(ts)` に複合および単一索引。これがないと利用状況の集計と日次削除が全表走査になり、単一ライターのスループットを圧迫する。

スキーマ移行は `PRAGMA user_version` による適用済み管理で行う。`request_logs` は保持日数を設定して日次で削除し、週次で WAL のチェックポイントと領域回収を行う。

`client_ip` は Apache が付与した転送元ヘッダの**最右端**の値から取る。クライアント由来の値を先頭から取ると監査ログを詐称できる。

### 4.10 control API

admin から呼ばれる内部 API。Compose ネットワーク内のみで到達可能とし、ホストへは公開しない。

| メソッド | パス | 用途 |
|---|---|---|
| GET | `/healthz` | ヘルスチェック。Docker healthcheck が使用 |
| GET / POST | `/keys` | キー一覧 / 発行 |
| PATCH / DELETE | `/keys/{id}` | 更新 / 削除 |
| GET | `/usage` | 使用状況集計 |
| GET | `/models` | 上流の実モデル一覧をキャッシュ付きで取得 |
| GET | `/audit` | 監査ログ参照 |

認証は共有シークレット。これは論理的な境界であり、実質的な境界は Compose ネットワークの分離である点を明記する。全ての更新系操作と認証失敗を監査ログに記録する。

### 4.11 プロセス構成

gateway は1つのコンテナで公開用と control 用の2つのリスナーを持つ。テンプレートは1コンテナ1ポートを前提とするため、起動方式を明示的に定める。

- 単一プロセス内で2つのサーバーインスタンスを同一イベントループ上に並べて起動する
- どちらかが異常終了した場合はプロセス全体を終了させ、コンテナ再起動に委ねる
- 追加の環境変数として `CONTROL_PORT` を規約に加える
- 終了シグナル受信時は新規受理を止め、進行中のストリームを一定時間待避してから終了する

将来の水平分散では、公開系を複数化し control 系を単一に分離する。その時点でレート制限カウンタとログ書き込みの共有化が必要になる。SQLite は複数プロセスからの同時書き込みに弱いため、レート制限だけでなくログ書き込みの集約も再設計対象となる。

## 5. admin の設計

| 項目 | 内容 |
|---|---|
| スタック | FastAPI + Jinja2 + HTMX。ビルド工程を持たない |
| マウント | `root_path=/admin` |
| 認証 | 単一管理者。環境変数の値を Argon2 で照合。署名付きセッション Cookie |
| Cookie 属性 | `Secure` `HttpOnly` `SameSite=Strict` |
| CSRF | 状態変更系にトークン検証。HTMX のヘッダ付与機能で送出 |
| ログイン保護 | 失敗回数による指数バックオフとロックアウト。初期パスワードの強度検査 |
| 追加ヘッダ | `X-Frame-Options: DENY` |
| 画面 | ログイン、キー一覧、キー発行、キー編集、使用状況、監査ログ |

管理画面は課金とアクセス制御の中枢であるため、アプリ層の認証だけに依存しない。Apache 側の接続元制限を既定の設計として組み込む。

起動時に control API が未準備である場合に備え、リトライとバックオフを実装する。

## 6. リポジトリ構成

| リポジトリ | 役割 | ポート |
|---|---|---|
| `dmajima/ai-disk-gateway`（親） | docker-compose.yml、deploy.sh、vhost テンプレート、CLAUDE.md、docs | |
| `dmajima/llm-gateway-proxy` | ゲートウェイ本体。submodule 名 `gateway` | 3000 公開 / 3001 内部 |
| `dmajima/llm-gateway-admin` | 管理画面。submodule 名 `admin` | 4000 |

親リポジトリは project-template をクローンして `init.sh` を実行して生成する。ポートはテンプレートの既定採番に一致する。

`init.sh` は1インターフェース1ポートしか生成しないため、次の手当てをフェーズ0の作業項目とする。

- `docker-compose.yml` に `CONTROL_PORT` を追記する。control ポートは `ports` に載せずコンテナ内部に留める
- 自動生成された CLAUDE.md と README のポート表を手で修正する
- 環境変数は `env_file: .env` 方式で流し込む。変数追加のたびに compose を編集せずに済む
- 各サービスに `mem_limit` を設定する。gateway は 1 GiB、admin は 512 MiB を初期値とする
- `logging` にサイズ上限と世代数を設定する
- gateway に control ポートを使う healthcheck を設定し、admin の依存条件を正常判定待ちにする

各子リポジトリのルートには `Dockerfile` を置き、`PORT` と `HOST` を環境変数で解釈する規約に従う。`.env` はイメージへ焼き込まない。

## 7. デプロイ自動化

親リポジトリの `deploy.sh` を単一のエントリポイントとする。冪等に作り、多重起動は排他ロックで防ぐ。

```mermaid
flowchart TD
    S["deploy.sh 実行"] --> L["排他ロック取得"]
    L --> P1["前提チェック<br/>sudo / git / curl / ディスク"]
    P1 --> P2["Docker と Compose を導入<br/>非対話モード指定"]
    P2 --> P2b["コンテナから上流への<br/>到達性を検証"]
    P2b --> P3[".env 検証と権限設定<br/>不足シークレットを CSPRNG で生成"]
    P3 --> P4["submodule 取得"]
    P4 --> P5["build と up -d<br/>healthcheck 正常化を待機"]
    P5 --> P6["Apache モジュール有効化"]
    P6 --> P6b["既存 vhost を .bak へ退避"]
    P6b --> P7{"証明書あり?<br/>sudo で判定"}
    P7 -->|"なし"| C1["80番のみの vhost を配置"]
    C1 --> C2["certbot certonly<br/>非対話フラグ付き"]
    C2 --> P8
    P7 -->|"あり"| P8["443 vhost を生成配置"]
    P8 --> P9["configtest と reload"]
    P9 --> P10["疎通確認<br/>新旧サイト両方"]
    P10 --> R{"成功?"}
    R -->|"否"| RB["初回なら a2dissite と削除<br/>更新なら .bak を復元<br/>reload して非ゼロ終了"]
    R -->|"可"| OK["完了"]
```

### 7.1 実装上の必須事項

レビューで指摘された、無人実行を壊す具体的な落とし穴に対処する。

| 項目 | 対処 |
|---|---|
| 証明書の存在判定 | `/etc/letsencrypt/live` は root のみ参照可。判定を `sudo` 経由に統一する。誤判定は発行回数制限への抵触につながる |
| certbot の対話待ち | `--non-interactive --agree-tos -m "$CERTBOT_EMAIL" --no-eff-email --cert-name ai.disk.mydns.jp` を付ける |
| apt の対話待ち | `DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a` を指定する |
| docker グループ権限 | 追加直後は同一セッションに反映されない。`sudo docker compose` に統一する |
| ロールバックの分岐 | 初回は戻す先がない。既存 vhost を `.bak` に退避し、あれば復元、なければ無効化して削除する |
| 更新フックの登録 | 発行時フラグに頼らず `/etc/letsencrypt/renewal-hooks/deploy/` へ静的配置し、全証明書で共通化する |
| webroot の権限 | certbot は root で書き、Apache は www-data で読む。走査権限を確認する |
| 上流への到達性 | `DEFAULT_FORWARD_POLICY` が `DROP` のため、導入後にコンテナから上流へ出られるかを必ず検証する |
| 既存サイトへの影響 | 導入直後に既存ドメインと内部 nginx の応答を実測する |

### 7.2 スクリプト構成

| ファイル | 役割 |
|---|---|
| `deploy.sh` | エントリポイント |
| `.deploy/lib.sh` | 各ステップの関数群 |
| `.deploy/apache-vhost.conf.tpl` | vhost テンプレート |
| `.deploy/healthcheck.sh` | 疎通確認 |
| `.deploy/backup.sh` | データベースの日次退避 |
| `.deploy/monitor.sh` | 外形監視と証明書期限の確認 |
| `.env.example` | 必須環境変数の定義例 |

### 7.3 環境変数

| 変数 | 用途 |
|---|---|
| `DOMAIN` | `ai.disk.mydns.jp` |
| `UPSTREAM_BASE_URL` | `http://192.168.0.13` |
| `UPSTREAM_API_KEY` | llama-server の内部キー |
| `CONTROL_TOKEN` | gateway と admin 間の共有シークレット |
| `ADMIN_USER` / `ADMIN_PASSWORD_HASH` | 管理画面の管理者 |
| `SESSION_SECRET` | セッション Cookie の署名鍵 |
| `CERTBOT_EMAIL` | 証明書の連絡先 |
| `GLOBAL_CONCURRENCY` | 全キー横断の同時実行上限 |
| `PATH_POLICY` | `denylist` または `allowlist` |

`.env` は権限 600 で配置し、所有者をデプロイ実行ユーザーに限定する。本ホストは既存サイトと同居する共用ホストであり、既定権限のままだと同居プロセスから内部キーを読まれる。

未設定のシークレットは `openssl rand` または `secrets` モジュールで生成し、`.env` へ書き戻して以降は再利用する。毎回生成すると gateway と admin の間で共有シークレットが食い違う。

### 7.4 運用の自動化

| 項目 | 方式 |
|---|---|
| バックアップ | systemd タイマーで日次。SQLite のバックアップ API で整合性のある複製を取り、ホスト外へ退避 |
| 監視 | 外形監視でゲートウェイとコンテナの死活を確認。証明書の残存期間も併せて確認 |
| ログ | コンテナ標準出力にサイズ上限と世代数。データベースの利用ログは保持日数で削除 |
| 証明書更新 | certbot のタイマーに委ね、更新フックで Apache を再読込 |
| アプリの切り戻し | submodule 参照を1つ前へ戻して再構築する手順を文書化 |

## 8. フェーズ分割

| フェーズ | 内容 | 主な成果物 |
|---|---|---|
| 0 | リポジトリ3件の作成、`init.sh` 実行、compose の手当て | 親リポジトリ、`.gitmodules`、`docker-compose.yml` |
| 1 | gateway 実装 | 中継処理、認証、レート制限、モデル許可、control API、単体試験、Dockerfile |
| 2 | admin 実装 | 画面6種、control API クライアント、Dockerfile |
| 3 | デプロイ自動化 | `deploy.sh`、`.deploy/`、vhost テンプレート、バックアップ、監視 |
| 4 | 検証と受入 | 検証スクリプト、README、CLAUDE.md |

フェーズ1と2は control API のインターフェースを先に固定すれば並行できる。

## 9. 受入条件

| # | 条件 | 検証方法 |
|---|---|---|
| 1 | リクエストボディがバイト単位で上流に届く | エコーサーバを上流に立て、送受信のハッシュを比較 |
| 2 | llama.cpp 固有パラメータが欠落しない | `grammar` `cache_prompt` `n_probs` を含む要求で到達を確認 |
| 3 | SSE が逐次到達する | `curl -N` でチャンクの到達間隔を計測 |
| 4 | 上限超過で 429 が返る | 上限を超える連投で応答コードと `Retry-After` を確認 |
| 5 | 許可外モデルで 403 が返る | 許可リスト外のモデル名で要求 |
| 6 | モデル検査の迂回ができない | `Content-Type` の変更、本文の肥大化、旧来の生成パスの3経路で試行 |
| 7 | 管理系パスが拒否される | スロット照会パスへの要求が 404 になることを確認 |
| 8 | 全キー横断の同時実行上限が効く | 上限超の長時間ストリームを並べて後続が待たされることを確認 |
| 9 | `/v1/models` が許可モデルのみ返す | 制限付きキーで取得 |
| 10 | 内部キーが外部に漏れない | 応答ヘッダ、本文、エラー時の内容、例外ログを検査 |
| 11 | 管理画面が未認証で操作できない | 未認証アクセスとクロスサイト送信の両方を試行 |
| 12 | `deploy.sh` が二重実行で壊れない | 連続2回実行して差分と稼働状態を確認 |
| 13 | 既存サイトが無傷 | Docker 導入前後で既存ドメインと内部 nginx の応答を比較 |
| 14 | 証明書が自動更新される | 本番 vhost 適用後に `certbot renew --dry-run` |
| 15 | バックアップから復旧できる | 退避した複製から復元して起動 |

受入条件14は本番 vhost を適用した後に実行する。ブートストラップ段階の vhost で試しても、更新経路の検証にはならない。

## 10. リスクと対策

| リスク | 影響 | 対策 |
|---|---|---|
| Apache が SSE をバッファする | ストリーミングが途切れる | `flushpackets=on` をまず適用。なお解消しなければ既存 nginx を挟む構成へ切り替える |
| Docker 導入で上流へ出られなくなる | 全機能停止 | 導入直後に到達性を検証。失敗時は転送許可の追加で対処 |
| コンテナの暴走 | 同居サイトを巻き込む | メモリ上限を設定 |
| SQLite の破損や喪失 | 発行済みキーの全喪失 | 日次退避と復旧手順の検証 |
| 単一プロセス前提 | 水平分散時に再設計 | カウンタとログ書き込みの共有化が必要になる点を記録 |
| 再デプロイでストリームが切れる | 生成中の要求が失われる | 終了時の待避処理。低トラフィック時間帯での実施 |
| 管理画面の総当たり | ゲートウェイ全体の侵害 | 接続元制限、ロックアウト、監査ログ |
| 共用ホストからの秘密情報読み取り | 内部キーの窃取 | `.env` の権限 600、イメージへ焼き込まない |

## 11. 依存関係のトレードオフ

gateway と admin は同一言語・同一フレームワークで揃える。運用コストは下がるが、共通依存の脆弱性が両方へ同時に波及する。この規模では運用の単純さを優先する判断とし、依存更新は両リポジトリで同時に行う運用とする。

## 12. v1 からの主な変更点

| 分類 | 変更 |
|---|---|
| セキュリティ | モデル検査を検査不能時に拒否する方式へ変更 |
| セキュリティ | 管理系パスの拒否リストを導入。旧来の生成パスも検査対象に追加 |
| セキュリティ | 全キー横断の同時実行上限、無通信タイムアウト、最大所要時間を追加 |
| セキュリティ | 逸脱パラメータの拒否、内部キーのマスクと安全網、監査ログを追加 |
| セキュリティ | `.env` の権限、シークレットの生成方式と永続化を規定 |
| セキュリティ | Cookie 属性、CSRF、ロックアウト、接続元制限、HSTS を追加 |
| 設計 | 管理者アカウントのテーブルを廃止し環境変数認証へ変更 |
| 設計 | gateway の2リスナー起動方式と終了時の待避を規定 |
| 設計 | 索引、スキーマ移行、領域回収、転送元ヘッダの信頼位置を規定 |
| インフラ | `flushpackets=on` を必須化。`http2` を対象から除外 |
| インフラ | certbot と apt の非対話フラグ、sudo での証明書判定を規定 |
| インフラ | ロールバックの初回と更新の分岐、排他ロックを追加 |
| インフラ | control ポートをホストへ公開しない方針へ変更 |
| インフラ | メモリ上限、ログ上限、healthcheck と依存条件を追加 |
| 運用 | バックアップ、監視、切り戻し手順を成果物に追加 |
