GeminiServerコンポーネント - ha1t/php-gm-server GitHub Wiki

GeminiServerコンポーネント

GeminiServerコンポーネントは、php-gm-serverプロジェクトの中核を担うメインサーバークラスです。ReactPHPのイベントループを活用した非同期I/Oの実装により、TCP/TLS接続を管理し、クライアントからのGeminiリクエストを受け付けて処理フローを調整します。リクエストパース、ファイルハンドリング、レスポンス生成まで、一連の処理流を統括します。

コンポーネントの役割

GeminiServerクラスはシステムの司令塔として機能します。以下の責務を担当します:

  • TCPサーバーの初期化と起動: TcpServerを生成してバインドし、指定ホスト・ポートでリッスン開始
  • TLS/SSL暗号化通信の実装: SecureServerでTCPをラップし、自己署名証明書を用いたセキュアな通信を実現
  • クライアント接続の受け入れと管理: 接続イベント、データイベント、エラーイベントのハンドラーを登録
  • バッファ管理: 不完全なリクエストデータを蓄積し、終端(\r\n)検出時に処理開始
  • リクエスト処理フローの調整: RequestParser、StaticFileHandler、ResponseBuilderを連携させて完全な処理フロー実現
  • エラーハンドリング: リクエスト長超過、パース失敗、ファイル不在など各エラー条件に対応

TcpServerとSecureServerの階層構造

ReactPHPのソケット実装では、ネットワーク通信層を段階的に構築します。GeminiServerはこの2層構造を採用しています。

TcpServer(TCP层)
    ↓ ラップ
SecureServer(TLS/SSL层)
    ↓ イベント
ConnectionInterface(クライアント接続)

TcpServer (React\Socket\TcpServer)は、OS レベルのTCPソケットをバインドし、インカミング接続を受け入れるための最下位層です。指定されたホストとポート(Gemini標準ポート1965)でリッスンし、新しい接続が確立されるたびにConnectionInterfaceオブジェクトを生成します。

SecureServer (React\Socket\SecureServer)は、TcpServerをラップして、暗号化通信レイヤーを追加します。OpenSSLを使用してTLSハンドシェイクを実行し、クライアント側の証明書検証は無効化(verify_peer: false)します。これはGemini仕様のTOFU(Trust On First Use)モデルに対応しています。

実装例:

$tcp = new TcpServer("{$this->host}:{$this->port}", $loop);
$server = new SecureServer($tcp, $loop, [
    'local_cert' => $this->certPath,
    'allow_self_signed' => true,
    'verify_peer' => false,
]);

Sources: src/GeminiServer.php:35-40

イベントループとの統合

GeminiServerはReact\EventLoop\Loop::get()で取得したグローバルイベントループに統合され、非同期の事象駆動型処理を実現します。

$loop = Loop::get();
// ...
$loop->run();

このrun()呼び出しによって、イベントループがブロッキング状態に入り、登録されたすべてのコールバックを監視します。TcpServerもSecureServerも、このループ内で非同期的に動作し、複数のクライアント接続を同時処理できます。

Sources: src/GeminiServer.php:33,89

接続ハンドラーの処理フロー

SecureServerのconnectionイベント時に、新規接続ごとにコールバック関数が実行されます。コールバック内で各接続に対するデータやエラーリスナーを登録します。

graph TD
    A["SecureServer connection イベント発火"] -->|ConnectionInterface 生成| B["コールバック実行"]
    B --> C["error イベント登録"]
    B --> D["data イベント登録"]
    C --> E["接続エラー検出時に出力"]
    D --> F["受け取ったデータをバッファに蓄積"]
Loading

エラーハンドラー (connection.on('error')): 接続中に発生したいかなるエラー(ネットワークエラー、TLSハンドシェイク失敗など)をキャッチし、エラーメッセージを出力します。ハンドラー実行後は自動的に接続がクローズされます。

Sources: src/GeminiServer.php:44-47

バッファ管理と不完全リクエストの処理

クライアントからのデータは分割して到達することがあるため、GeminiServerはバッファを保有して段階的にデータを蓄積します。

$buffer = '';

$conn->on('data', function (string $data) use ($conn, &$buffer) {
    $buffer .= $data;

    if (!str_contains($buffer, "\r\n")) {
        if (strlen($buffer) > 1024) {
            $conn->write(ResponseBuilder::badRequest('Request too long'));
            $conn->end();
        }
        return;
    }
    // 以降の処理
});

バッファ戦略の詳細

  1. データ追加フェーズ: dataイベントごとにバッファに新着データを+=で追記
  2. 完全性チェック: \r\n(CRLF終端)の検出でリクエスト完全性を判定
  3. 長さチェック: リクエスト未完全かつ1024バイト超の場合、即座にbadRequestレスポンスを返却してコネクション終了
  4. 長さチェック合格時: リクエスト完全まで待機(returnで再度dataイベント待機)

このバッファ戦略により、バッファオーバーフロー攻撃を防止し、リクエスト長制限(Gemini仕様)を強制できます。

Sources: src/GeminiServer.php:49-60

リクエストの検証と処理

バッファに\r\nが検出されたら、リクエスト文字列をRequestParserに渡してパースします。

$request = RequestParser::parse($buffer);

if ($request === null) {
    $conn->write(ResponseBuilder::badRequest('Invalid request'));
    $conn->end();
    return;
}

echo "Request: {$request['host']}{$request['path']}\n";

RequestParserは以下の検証を実施します:

検証項目 詳細
スキーム検証 geminiスキーム以外はnullを返却
ホスト名抽出 空文字列の場合はnullを返却
パス抽出 パス未指定の場合は/をデフォルトとして設定
長さチェック リクエスト長が1024バイト超の場合はnullを返却

Sources: src/RequestParser.php:14-47

ファイル処理とMIMEタイプ判定

パース成功後、リクエストパスをStaticFileHandlerに渡して、ファイル解決とMIMEタイプ判定を実施します。

$result = $this->fileHandler->handle($request['path']);

if ($result === null) {
    $conn->write(ResponseBuilder::notFound());
    $conn->end();
    return;
}

$conn->write(ResponseBuilder::success($result['mime'], $result['body']));
$conn->end();

StaticFileHandlerの処理フロー:

graph TD
    A["パスを受け取る"] --> B["ドキュメントルート + パスを結合"]
    B --> C{ディレクトリ?}
    C -->|はい| D["パス末尾に index.gmi を追加"]
    C -->|いいえ| E["realpath で正規化"]
    D --> E
    E --> F{パストラバーサル<br/>チェック}
    F -->|失敗| G["null を返却"]
    F -->|成功| H{ファイル存在?}
    H -->|いいえ| G
    H -->|はい| I["ファイル内容を読み込み"]
    I --> J["拡張子から MIME タイプを判定"]
    J --> K["mime と body を返却"]
Loading

パストラバーサル防止メカニズム: StaticFileHandlerはrealpath()で完全パスを正規化した後、str_starts_with()でドキュメントルート配下であることを確認します。これにより、../を含むパス攻撃を検出・拒否できます。

$realPath = realpath($filePath);
if ($realPath !== $this->docRoot && !str_starts_with($realPath, $this->docRoot . DIRECTORY_SEPARATOR)) {
    return null;
}

MIME マップ

拡張子 MIMEタイプ
gmi, gemini text/gemini
txt text/plain
html text/html
css text/css
js text/javascript
json application/json
png image/png
jpg, jpeg image/jpeg
gif image/gif
svg image/svg+xml
pdf application/pdf

Sources: src/StaticFileHandler.php:35-68

レスポンス構築と送信

ファイルハンドラーから結果が返却されたら、ResponseBuilderでGeminiプロトコル形式のレスポンスを構築し、クライアント接続に書き込みます。

graph TD
    A["ファイルハンドラーの結果"] --> B{結果判定}
    B -->|見つからない| C["ResponseBuilder::notFound"]
    C --> D["51 Not found を送信"]
    B -->|成功| E["ResponseBuilder::success"]
    E --> F["20 MIME を送信"]
    F --> G["ボディを続続送信"]
    D --> H["接続をクローズ"]
    G --> H
Loading

Geminiレスポンス形式

STATUS SPACE META\r\n
BODY
  • 成功時(20):

    20 text/gemini\r\n
    [ファイル内容]
    
  • 見つからない(51):

    51 Not found\r\n
    
  • 不正なリクエスト(59):

    59 Bad request\r\n
    

Sources: src/ResponseBuilder.php:9-22

エラーハンドリング戦略

GeminiServerは以下のエラー条件を処理します:

エラー条件 ステータスコード 処理内容
接続エラー(TLSハンドシェイク失敗など) なし エラーメッセージ出力後、接続自動クローズ
リクエスト長超過(>1024バイト) 59 "Request too long" メッセージで応答、接続クローズ
リクエストパース失敗 59 "Invalid request" メッセージで応答、接続クローズ
ファイル不在 51 "Not found" で応答、接続クローズ

すべてのエラーレスポンス送信後、$conn->end()で接続を明示的にクローズします。これにより、クライアント側でレスポンスの完全受信を認識できます。

Sources: src/GeminiServer.php:45-82

完全なリクエスト-レスポンスフロー

以下は、TLS接続確立からレスポンス送信まで、一連の処理を時系列で示したシーケンス図です。

sequenceDiagram
    participant Client as クライアント<br/>(Gemini端末)
    participant Server as GeminiServer<br/>(イベントループ)
    participant Parser as RequestParser
    participant Handler as StaticFileHandler
    participant Builder as ResponseBuilder

    Client->>Server: TLS接続確立<br/>(SecureServer処理)
    Server->>Server: connection イベント発火<br/>バッファ初期化
    Client->>Server: リクエスト送信<br/>(gemini://...)
    Server->>Server: data イベント発火<br/>バッファに蓄積
    Server->>Server: CRLF検出?
    alt CRLF未検出
        Server->>Server: 長さ > 1024?
        alt YES
            Server->>Client: 59 Request too long
            Server->>Server: 接続クローズ
        else NO
            Server->>Server: 次の data を待機
        end
    else CRLF検出
        Server->>Parser: parse(バッファ)
        alt パース失敗
            Parser-->>Server: null
            Server->>Client: 59 Invalid request
            Server->>Server: 接続クローズ
        else パース成功
            Parser-->>Server: {host, path}
            Server->>Handler: handle(パス)
            alt ファイル見つからない
                Handler-->>Server: null
                Server->>Client: 51 Not found
                Server->>Server: 接続クローズ
            else ファイル見つかった
                Handler-->>Server: {mime, body}
                Server->>Builder: success(mime, body)
                Builder-->>Server: Geminiレスポンス
                Server->>Client: 20 MIME\r\nBODY
                Server->>Server: 接続クローズ
            end
        end
    end
Loading

パラメータと初期化

GeminiServerコンストラクタは以下のパラメータを受け取ります:

public function __construct(
    string $host,           // バインドするホストアドレス(デフォルト: 0.0.0.0)
    int $port,              // バインドするポート番号(デフォルト: 1965)
    StaticFileHandler $fileHandler,  // ファイルハンドラーインスタンス
    string $certPath,       // TLS証明書ファイルパス(PEM形式)
)

初期化例(bin/server.php):

$host = getenv('GEMINI_HOST') ?: '0.0.0.0';
$port = (int) (getenv('GEMINI_PORT') ?: 1965);
$docRoot = getenv('GEMINI_DOC_ROOT') ?: __DIR__ . '/../content';
$certDir = getenv('GEMINI_CERT_DIR') ?: __DIR__ . '/../certs';
$hostname = getenv('GEMINI_HOSTNAME') ?: 'localhost';

$certGenerator = new CertificateGenerator($certDir);
$certPath = $certGenerator->generate($hostname);

$fileHandler = new StaticFileHandler($docRoot);
$server = new GeminiServer($host, $port, $fileHandler, $certPath);
$server->run();

Sources: bin/server.php:11-23

メモリ管理とリソースクリーンアップ

各クライアント接続に対して、バッファ変数$bufferがクロージャ内のuseスコープで管理されます。$conn->end()でコネクションがクローズされると、そのコネクション対応のクロージャもスコープ外となり、バッファメモリは自動的にガベージコレクションの対象となります。

$buffer = '';  // コネクションごとにスタック上に確保

$conn->on('data', function (string $data) use ($conn, &$buffer) {
    // クロージャ内でバッファを管理
});

// $conn->end() 実行 → コネクション終了 → クロージャスコープ終了 → $buffer解放

このアーキテクチャにより、長時間実行されるサーバープロセスでもメモリリークを防止できます。

パフォーマンス特性

  • 非同期I/O: ReactPHPのイベントループにより、ブロッキング処理なしで複数クライアントを同時処理
  • バッファストリーミング: ファイル全体をメモリに読み込むため、大容量ファイル配信時のメモリ使用量はfile_get_contents()の制約を受ける
  • パストラバーサル防止: realpath()呼び出しでディスクI/O発生(セキュリティとのトレードオフ)

制限事項と既知の仕様

  • リクエスト長上限: 1024バイト(Gemini仕様)
  • TLS必須: 自己署名証明書のみサポート
  • ホスト検証: デフォルト無効(TOFU モデル準拠)
  • ファイル配信: メモリ読み込みのため、極度に大きなファイルは非効率

Sources: src/GeminiServer.php:1-91composer.json:1-23

Related Pages

⚠️ **GitHub.com Fallback** ⚠️