「見るだけ」から「操作できる」へ ― MinecraftサーバーへのRCON実行を、個人開発でどう安全に作ったか
個人開発しているMinecraftサーバー監視アプリ「MineWatch」(公式サイト、アプリの全体像はこちら)は、当初は「見るだけ」の読み取り専用の監視アプリでした。そこにRCON(Minecraftサーバーへのリモートコマンド実行プロトコル)経由でコマンドを送れる機能を追加しました。stop一発でサーバーを落とせる、質的に違う機能です。この記事は、その設計でどこを懸念し、どう対処したかをまとめたものです。
TL;DR
- 読み取り専用の監視から「操作できる」機能への追加は、着手前に受け入れ基準として懸念点を先に言語化してから実装した。「接続情報の暗号化保存」「実行前の確認ダイアログ」「実行履歴の記録」「パスワードを絶対にログへ出さない」の4点。
- RCON接続情報(ホスト・ポート・パスワード)は、既存の監視先ホストと同じFernet暗号化のカラム型でDBに保存し、パスワードは書き込み専用(APIレスポンスにも画面にも一切出てこない)にした。
- RCON自体の接続・認証・タイムアウト失敗は例外として投げず、「成功しなかった1件の実行記録」として扱う。呼び出し元のAPIとしては200が正しい、という設計判断。
- 実行のたびに監査ログ(誰が・いつ・何を・成功したか)を1行残す。冪等性キーで二重実行も防ぐ。
- サーバー単位のレート制限で、意図しない連打を防ぐ。
1. 何を懸念したか
この機能のGitHub issueには、着手前に次の受け入れ基準を書きました。
- サーバー詳細画面からRCONコマンドを実行できるUIを追加
- RCONホスト/ポート/パスワードは既存の暗号化方式でDB保存
- 破壊的コマンド(stop等)は実行前に確認ダイアログを挟む
- 実行履歴(誰が・いつ・何を実行したか)をログ/DBに残す
- RCONパスワードは絶対にログに出力しない
- RCON接続失敗時の扱い(エラー表示)を設計する
理由は明快です。それまでのMineWatchの機能は、サーバーの状態をポーリングして表示するだけの読み取り専用でした。RCONは、ユーザーの誤操作やUIのバグが、そのまま実際のMinecraftサーバーの停止・キックといった不可逆な操作に直結します。実装を始める前に、何を守るべきかを先に決めておく必要がありました。
2. 接続情報: 暗号化して保存し、絶対に返さない
RCONのホスト・ポート・パスワードは、既存の監視先ホスト(servers.host)と同じ暗号化カラム型(Fernet、複数鍵によるローテーション対応のMultiFernet)を流用してDBに保存します。
rcon_host: str | None = Field(
default=None, sa_column=Column(EncryptedString(512), nullable=True)
)
rcon_port: int | None = Field(default=None)
rcon_password: str | None = Field(
default=None, sa_column=Column(EncryptedString(512), nullable=True)
)
パスワードの扱いで徹底したのは、書き込み専用にすることです。設定状態を返すエンドポイントは、ホスト・ポートは返してもパスワードは絶対に含めません。
@router.get("")
async def get_rcon_status(server: ServerDep) -> RconStatusResponse:
"""RCON設定状態を返す。パスワードは含めない."""
return RconStatusResponse(
configured=rcon_service.is_configured(server),
rcon_host=server.rcon_host,
rcon_port=server.rcon_port,
)
iOS側の接続情報入力画面も同じ方針です。パスワード欄は編集時にも既存の値を表示しません。一度書き込んだパスワードを、後から画面上で読み返す手段自体が存在しない作りにしています。
3. 失敗を例外にしない: RCON自体の失敗は「成功しなかった記録」
実装で工夫したのは、RCONへの接続・認証・タイムアウトが失敗したときの扱いです。これらを例外としてAPI層まで伝播させるのではなく、「実行はしたが成功しなかった」という1件の記録として扱います。
try:
async with mc_rcon.Client(
server.rcon_host, server.rcon_port, server.rcon_password,
timeout=settings.rcon_timeout,
) as client:
response_text = await client.command(command)
success = True
except mc_rcon.RCONAuthError as exc:
error_text = f"RCON認証に失敗しました: {exc}"
except mc_rcon.RCONTimeoutError as exc:
error_text = f"RCON接続がタイムアウトしました: {exc}"
except mc_rcon.RCONConnectionError as exc:
error_text = f"RCONへの接続に失敗しました: {exc}"
except Exception:
# mc_rconの想定例外以外(予期しないOSError等)が漏れて未処理の500に
# なるのを防ぐ。詳細はログにだけ残し、ユーザーには汎用メッセージを返す。
logger.exception("unexpected error during rcon command", extra={...})
error_text = "RCONコマンドの実行に失敗しました。"
コード内のコメントに、この設計の考え方が書かれています。「MineWatchへのリクエスト」自体は正常に処理できているので、HTTPレスポンスとしては200が正しく、失敗したのは「RCON越しの操作そのもの」だという整理です。さらに、mc_rconが投げることを想定していない例外(予期しないOSError等)もexcept Exceptionで受け止め、詳細はログにだけ残してユーザーには汎用的なメッセージだけを返します。想定外のエラーメッセージがそのままレスポンスに漏れて、内部の実装詳細が外に見えてしまう事故を防ぐためです。
4. 監査ログ: 誰が・いつ・何を実行したか
実行のたびに、成功・失敗どちらも1行として記録します。
class RconCommandLog(SQLModel, table=True):
"""RCON経由で実行した1コマンドの記録."""
__tablename__ = "rcon_command_logs"
id: int | None = Field(default=None, primary_key=True)
server_id: int = Field(foreign_key="servers.id", ondelete="CASCADE", index=True)
user_id: int = Field(foreign_key="users.id", ondelete="CASCADE", index=True)
command: str = Field(sa_column=Column(Text, nullable=False))
success: bool = Field(sa_column=Column(Boolean, nullable=False))
response: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
error: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
executed_at: datetime | None = Field(
default=None,
sa_column=Column(DateTime(timezone=True), server_default=func.now(), nullable=False),
)
server_id・user_idともondelete="CASCADE"にしています。サーバーやアカウントを削除すれば、この実行履歴も連鎖して消えます。監視の状態履歴(server_status)と同じ、「監査ログは監視対象そのものと運命を共にする」という一貫した方針です。
もう1つ、実装の途中でコードレビュー(AIレビュー、前回紹介した仕組み)から指摘された点があります。履歴一覧APIの総件数は、取得件数の上限(50件)で切り詰める前の実件数を返す必要がありました。len(logs)をそのまま件数に使うと、51件目以降が存在するのに「50件」と過小報告してしまう不具合になるところでした。
5. パスワードをログに出さない
ロギング設計のドキュメントには、こう明記しています。
RCONパスワード等はログに出さない。Lokiに入ってしまうと保持期間中は消せないため、出力の時点で止める。
RCONコマンドの実行結果をログに出すときも、実際に出しているのはサーバーID・コマンド文字列・成否だけです。
logger.log(
logging.INFO if success else logging.WARNING,
"rcon command server=%s -> %s",
server.id,
"success" if success else "failure",
extra={
"minecraft.server.id": server.id,
"minecraft.server.name": server.name,
"rcon.command": command,
"rcon.success": success,
},
)
コード上のコメントには「host/portはログに出さない(監視のログと同じ方針)。パスワードは元よりモデルにもログにも一切載せない」とあります。ログに何を出すかを、フィールド単位のallowlistで明示的に決めており、「オブジェクトを丸ごとstr()してログに流す」ような、うっかり秘匿情報が漏れる書き方を避けています。
6. レート制限と冪等性: 連打と二重実行を防ぐ
サーバー単位でのレート制限(60秒間に20回まで)を入れています。これはコードレビューでの指摘がきっかけで追加したものです。対象は認証済み・所有者限定のエンドポイントなので、問い合わせフォームのような匿名フォームのレート制限より緩めですが、「対象のMinecraftサーバーへ向かうRCONのトラフィックそのものを絞りたい」という理由で、IPアドレスではなくサーバーID単位にしています。
もう1つ、Idempotency-Keyヘッダによる冪等性の仕組みも用意しました。stopのような破壊的コマンドは、通信の不安定さでクライアントが再送してしまうと、意図せず何度も実行されるおそれがあります。同じキーでの再送は、最初の結果をそのまま返し、RCONへは実際には送信しません。キーにはユーザーID:サーバーID:呼び出し元のキーという形でスコープを付けており、他のユーザーや他のサーバーとキーが衝突して、キャッシュ済みのレスポンスを誤って返してしまう事故を防いでいます。
7. iOS側: 実行前の確認と、書き込み専用のパスワード欄
iOS側の画面遷移設計では、RCONコマンドの実行や接続情報の削除は、いずれも「確認アラート→実行」という2段階になっています。破壊的な操作を1タップだけで実行できないようにする、という受け入れ基準どおりの実装です。
接続情報の入力画面(RconCredentialsViewController)は、ホスト・ポート・パスワードの入力欄を持つモーダル画面ですが、パスワード欄は「書き込み専用」で統一されています。バックエンドがそもそもパスワードを返さないので、画面側も編集時に既存のパスワードを表示のしようがありません。
まとめ
- 「読み取り専用の監視」から「操作できる機能」への追加は、実装より先に「何を守るか」を受け入れ基準として言語化した。暗号化・確認ダイアログ・監査ログ・ログへの非出力の4点は、着手前に決めていたので実装中にブレなかった。
- RCON自体の失敗(接続・認証・タイムアウト)を例外として扱わず、「成功しなかった実行記録」として一貫して扱ったことで、エラーハンドリングが素直になった。
- 秘匿情報(パスワード)は、保存時は暗号化、APIレスポンスとログの両方で意図的に一切出さない、という一貫した方針を貫いた。
- レート制限・冪等性キーは、実装した後にレビューで指摘されて追加した部分もある。最初から完璧に懸念点を洗い出せるわけではなく、レビューを通じて追加で気づける設計の穴もある。
個人開発の環境での構成なので、そのまま組織に持ち込む場合は、監査ログの保持期間や、破壊的操作の権限管理(誰が実行してよいかの承認プロセス)を先に確認してください。
JQITのエンジニアの95%以上は未経験からの採用です。
よければコーポレートサイトにも遊びに来てください。
未経験から学べます!一緒に挑戦していきましょう![]()
noteやXもやってます↓