# Muse から自宅PCのTTSを安全に使うための専用Gatewayを作った話 —Irodori-TTS の連携で、SSHフルシェルをやめて「操作を絞った専用API」にした記録 対象読者:Muse(クラウド側エージェント)から、自宅PC上のローカルサービスを安全に叩きたい人と、その Muse。 状態:開発完了(2026-11-03)。常用運転への移行は別作業として未開始。 注:IPアドレス等は伏せてある。ポート番号や方式は実物のまま。 ## 完成形の構成 ```txt Muse(クラウド) │ Tailscale トンネルプロキシ(後述の3130)経由 │ 実socketの接続元を WhoIs で固定ノードIDと照合 ▼ Audio Gateway(自宅PC、Tailscale IPv4 の 8090 のみで待受) │ Muse からの操作:状態取得 / 音声生成のみ(認証判定と処理結果を監査) │ 停止は Windows 所有者のローカル操作(Muse からは止められない) ▼ Irodori-TTS RF サーバー(8089、従来どおり常駐) └─ PCM WAV → Gateway → Muse ``` - Gateway の起動は、所有者 Interactive/Limited(非管理者)で動くよう構成したログオンタスクによる。 - 起動はコンソール非依存(`pythonw.exe` 直接起動)。停止は所有者の Stop 操作 → drain(受付終了→処理中を完了→終了)。 - 起動可否は clean/dirty ゲートで管理する。異常終了すると dirty が残り、所有者が「未解決の生成がない」ことを確認するまで再起動しない。 ## 経緯:なぜ作ったか 1. **連携自体は先にできていた。** Muse から Tailscale 経由で自宅PCの Irodori(RF 8089/MF 8088)を叩いて音声生成する、という基本形は早い段階で動いていた。APIキーの代理差し替えがトンネル経由では効かない問題があり、当面は無認証+Tailscale 待ち受けで運用していた。 2. **不満は「Muse からサーバーを操作できない」こと。** 起動・停止は PC の前でしかできず、Muse 側から MF/RF を個別に上げ下げする手段がなかった。 3. **SSH 案が出た。** img の先行事例(Tailscale+SSH で PC を操作し、画像生成まで連携)に倣えば自由に操作できる、という発想。ただし、トンネル経由で SSH が抜けるか未検証だったことと、フルシェルは権限が広すぎることが問題になった。並行して、HTTP の制御サーバー案も用意した。 4. **Codex と ChatGPT のレビューで両案とも置き換わった。** シェルも汎用制御サーバーもやめて、必要な操作だけを認証・監査つきで出す専用 Gateway を段階的に作る方式へ。読み取り専用(Phase 2A)→ 音声生成(2B)→ 安全停止のライフサイクル(2C-1)、という順序もこのレビューの産物。 つまり動機は「生成」ではなく「操作権限を、最小の形で Muse に渡す」ことだった。 ## 設計の要点 - **認証は WhoIs。** リクエストの実 socket 接続元を Tailscale の WhoIs で引き、固定した Muse ノードの ID と一致した場合だけ通す。`X-Forwarded-For` 等のヘッダーは信用せず、付いていること自体を拒否理由にする(偽装ヘッダー付き GET → 403 を実機確認)。 - **生成の契約を絞る。** 許可 voice は固定リスト、入力長・キャプション長に上限、同時生成は1件、キューなし、自動再試行なし。タイムアウトは層で分ける。RF upstream は270秒。Muse 側ヘルパーの300秒はソケットタイムアウト(通信が止まったときの待ち上限)で、処理全体の絶対期限ではない。 - **停止は drain 方式(所有者のローカル操作)。** Muse に停止権限は渡していない。所有者の停止要求 → 新規受付終了 → 処理中の生成は完了まで待つ(上限300秒)→ 終端監査を書いてから終了。停止要求は所有者限定の state 領域へのファイル経由で、stdin に依存しない。 - **clean/dirty ゲート。** 起動時に dirty を立て、正常終了のときだけ clean に戻す。dirty が残った状態では起動を拒否する。生成途中で異常終了すると、RF が実行したか・結果が Muse に届いたかが不明(uncertain)になり得るが、その情報はメモリ上にあるためプロセス終了で失われる。だから異常状態をディスクに永続化し、所有者が「未解決の生成がない」ことと結果・配送状況を確認するまで、再起動も自動再送もしない。state を消して回避する運用は禁止にした。なお、これは完全な exactly-once 保証ではない。不明な結果を「なかったこと」にしないための仕組みである。 - **配置は保護領域へ。** コード・設定・ランタイムは保護領域に配置し、実行主体からの不用意な改変を制限する。state・監査領域は別に権限を管理し、Gateway の稼働と所有者操作に必要な範囲だけ書き込みを許可する。配置時にはハッシュと実効 ACL を検証する。所有者は非管理者のまま操作でき、管理者権限は要らない。 - **フォールバックを作らない。** Gateway が使えないとき、Muse が勝手に 8089 直通へ逃がすことはしない。障害時は人間に報告して判断を仰ぐ。 ## 開発の流れ(要約) | 段階 | 内容 | 結果 | | --- | --- | --- | | Phase 2A | 読み取り専用 Gateway(health/servers/gpu) | 実経路テスト合格 | | Phase 2B | ローカル生成 → リモート生成(単発) | WAV 受信・試聴まで合格 | | Phase 2C-1 | 安全停止ライフサイクル+試験監督 | 生成中の drain 実機試験に合格 | | 方針転換 | 専用ユーザー+S4U による OS 隔離の検討と、WhoIs 診断トラックを凍結 | 所有者 Interactive/Limited 方式で常用化を目指す | | 常用モード実装 | ログオン起動・常駐、停止要求ファイル、clean/dirty 永続化 | 実装・テスト完了 | | 保護配置 | 配置時に事故(後述)→ 原因調査 → 3点修正 | 既存配置は事後受入れで正規化 | | 限定実機試験群 | 起動・GET・偽装拒否・二重起動拒否・Stop・終了コード | 全項目合格 | | ログオンタスク登録 | 既存のタスク定義 XML をそのまま登録 | タスク経由の起動・通信・停止を実機確認 | | 音声POST実機試験 | daily モードで生成、Windows 監査と完全一致 | 合格 | | 経路切替 | Muse の通常ヘルパーの既定を 8089→8090 に変更 | (下記の事故を経て)Phase 2 で再確認し合格 | | コンソール非依存化 | `pythonw.exe` 直接起動へ移行 | 移行で2回失敗 → 単純化して完了 | | 開発完了 | 最終コミット `00f5c1b`、Python 247件/PowerShell モック35項目成功 | push 未実施 | 補足(方針転換について):専用ユーザーを使った初期検証では WhoIs が成功しなかった。ただし、この検証は S4U によるタスク起動そのものではなく(別ユーザー起動による検証で、実際のログオン種別も確定していない)、失敗原因も未確定だった。S4U 方式を含む専用実行主体の追加検証は保留し、実証済みの所有者 Interactive/Limited 方式を優先した。比較基準は「理想構成」ではなく、現行の 8089 無認証直通に置いた。 補足(テスト件数について):Phase 2A 時点で23件、Phase 2C-1 の最終回帰で165件、開発完了時点で Python 247件/PowerShell 35項目。ただし時期により集計範囲が異なるため、単純な累計の推移として読まないこと。 ## 転んだところ集(ここが本題かもしれない) ### 1. プロキシのポート違いで「Gateway が応答しない」になった Muse の実行環境には外部向け egress プロキシ(ポート3128)が環境変数で設定されていて、tailnet へは同じプロキシの **3130(トンネル用)** を明示しないと届かない。素の curl は 3128 を拾い、要求は Gateway に到達していなかったのに、症状は「応答が得られない」というだけで、Gateway 側の不具合に見えた。 **教訓:自側の送信経路(プロキシ・ポート・名前解決)を先に固定してから、相手を疑う。** 以後の試験スクリプトは全部、経路を明示する形に統一した。 ### 2. 安全ゲートは「実行のされ方」で迂回された 保護配置の手順は「検査に失敗したら止まる」前提で書かれていたが、所有者が PowerShell に1コマンドずつ貼って実行したため、途中の失敗後も後続が進み、未承認のまま初期 clean が作られた。呼び出し側が承認フラグを無条件で渡していた、という構造側の欠陥も重なっていた。 **教訓:安全条件はコードの中だけでなく、運用手順を含めて設計する。** 対策は、手順を検証済みの単一スクリプトにし、承認は保護されたコード自身が内部で取る形に変えた。 ### 3. 過剰判定が何度も出た - 親フォルダの「新規ファイル作成」権限を「置換権限」とみなして配置を拒否。 - タスク XML に `RunLevel` の明記がないことを理由に拒否(実際の値は Limited で正常)。 **教訓:形式ではなく実効値で判定する。** ただし緩めるのではなく、「実効値が安全だと positively に確認できた場合だけ通す」形に直した。 ### 4. タスク XML の比較は Windows の正規化と相性が悪い 移行スクリプトが2回止まった。1回目は、生成したタスク XML の処理が `Enabled` 要素の省略を想定しておらず停止。2回目は、登録済みタスクとの照合で SID/ユーザー名の表現差と UnifiedSchedulingEngine の差異を検出して停止した。Windows は要素の省略・既定値補完・識別子の表現を変えるため、要素単位の厳密比較は相手が OS だと崩れる。 **教訓:OS が管理する定義は、形式ではなく実効値で見る。** 最終解決は XML 比較の全面修正ではなく、移行スクリプトを継ぎ足すのをやめて、反映済みの構成を棚卸しし、必要最小限の受入れ処理で完了させた。 ### 5. コンソールを共有する常駐は、画面を閉じると死ぬ Gateway は PowerShell から同期起動されていて、所有者が黒いコンソール画面を閉じたときに一緒に終了した(終了コード `0xC000013A`、dirty 残存)。`-WindowStyle Hidden` は「隠す」だけで「コンソールを作らない」ではない。同じ閉じ方でも `Start-Process` 起動だった RF は生き残った。 **教訓:常駐プロセスはコンソールに依存させない。** 対策として `pythonw.exe` 直接起動に変え、黒い画面そのものが出ない構成にした。 ### 6. `exit` がホストごと閉じて、エラーが見えなくなる 移行スクリプト内外の `exit 1` が対話 PowerShell を終了させ、失敗理由が読めないまま画面が消えた。 **教訓:所有者に実行してもらうスクリプトは、失敗しても画面と理由が残る形(例外で停止、段階と理由を表示)にする。** ### 7. 進め方そのものの反省 検証の厳密さが目的化し、「テスト◯件成功」が増えても「Muse が普段から声を使える」状態が近づかない時期があった。途中で開発ルールを変えた。 - 理想の安全構成からでなく、「現行より安全で実用に必要な構成」から考える - 失敗したら検証工程を増やす前に、原因と影響を絞って最小修正を選ぶ - 承認は細切れでなく、影響範囲を明示した作業単位でまとめる - 手順は検証済みの単一実行スクリプトを優先する - 成果の評価は「実際に使える機能が増えたか」を第一にする (上記5項目は当時のルールの要約であり、原文そのままではない。) ## 現在の状態と残作業 - 開発は完了。Gateway は停止中、タスクは Disabled のまま維持している(常用運転への移行は別作業)。 - **到達範囲の区別:** 実現したのは「Gateway 経由の経路に対する認証・認可・監査」である。8089 の無認証直通経路はまだ閉じておらず、専用ユーザーによる OS 隔離も未達なので、システム全体が保護された状態ではない。現行より安全な経路が1本できた、というのが正確な到達点。 - 未検証・運用でこれから見るもの:新定義でのログオン自動起動の継続確認、長時間運転、PC スリープ復帰時の挙動。 - 8089 直通経路の制限は今後の作業。RF サーバー自体は Gateway の裏側で必要なので止めない。 - 当初の目的のうち「Muse から RF/MF を個別に起動・停止する」(Phase 2C-2)と WAV/ジョブ管理(2D)は未着手のまま。今回の Gateway は、その土台(認証・監査・安全停止)として使える。 ## 再現したい人向けの最小チェックリスト - [ ] tailnet 越しの経路を固定する(プロキシのポート違いに注意。疎通は実 socket で確認) - [ ] 認証はヘッダーでなく接続元の実 ID で(WhoIs 等)。Forwarded 系は不信 - [ ] 操作は列挙したものだけに絞り、生成は単発・上限・監査つき - [ ] 停止は所有者操作で drain(処理中を殺さない)、異常終了は dirty として永続化し、復旧は所有者確認を必須に - [ ] 常駐はコンソール非依存で。ログオンタスク+非管理者+自動再起動なしが扱いやすい - [ ] 所有者の実行手順は単一スクリプト化し、失敗時に理由が画面に残る形に - [ ] フォールバック(直通経路への自動切替)は作らない。困ったら人間に報告 ## 公開時に伏せる候補(運用メモ) - tailnet の実 IP・固定ノード ID・MagicDNS 名、PC のユーザー名・SID・実パス - プロキシや実行コマンドに含まれる認証情報、監査ログの生データ、音声ファイルそのもの - 実 ACL・Grants・Firewall の設定値(方式や原則の説明は可、実値は環境依存かつ防御情報)