リクエスト処理フロー - ha1t/php-gm-server GitHub Wiki

リクエスト処理フロー

クライアント接続からレスポンス送信までの完全なリクエスト処理フローを詳細に説明します。TLS接続確立、バッファ管理、リクエストパース、ファイルハンドリング、レスポンス構築の各段階を網羅します。このページは GeminiServerコンポーネント の具体的な実装メカニズムに焦点を当てており、アーキテクチャ設計 で示されるイベント駆動型設計がどのように動作するかを明らかにします。

サーバー起動と初期化

php-gm-server のリクエスト処理は、サーバー起動時の初期化から始まります。

// bin/server.php からの抜粋
$certGenerator = new CertificateGenerator($certDir);
$certPath = $certGenerator->generate($hostname);

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

初期化の流れ php-gm-server/bin/server.php:17-23

  1. CertificateGenerator により TLS証明書を生成(または既存証明書を再利用)
  2. StaticFileHandler がドキュメントルートのパスを正規化
  3. GeminiServer が TCP/TLS リスナーを起動

TLS接続確立

GeminiServer.run() メソッドにて、ReactPHP の TcpServer と SecureServer が初期化されます。

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

接続ハンドラーの登録 php-gm-server/src/GeminiServer.php:35-40

  • TcpServer で指定されたホスト・ポートでリッスン
  • SecureServer が TLS/SSL レイヤーを追加
  • 自己署名証明書を受け入れ(TOFU モデル)
  • ピア認証を無効化(サーバー認証のみ)

クライアント接続と 'connection' イベント

クライアントが接続するたびに 'connection' イベントが発火し、ConnectionInterface インスタンスが渡されます。

$server->on('connection', function (ConnectionInterface $conn) {
    $buffer = '';
    
    $conn->on('error', function (\Throwable $e) {
        echo "Connection error: {$e->getMessage()}\n";
    });
    
    $conn->on('data', function (string $data) use ($conn, &$buffer) {
        // リクエスト処理ロジック
    });
});

接続時の準備 php-gm-server/src/GeminiServer.php:44-50

  • バッファ変数 $buffer を初期化(リクエストデータを蓄積)
  • 接続固有のエラーハンドラーを登録
  • 'data' イベントリスナーを設定

リクエスト受信とバッファリング

クライアントから送られたデータは、'data' イベントハンドラーで受信され、バッファに蓄積されます。

$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;
    }
    
    // リクエストが完全(\r\n を含む)
    // 以降の処理へ進行
});

バッファリングロジック php-gm-server/src/GeminiServer.php:51-60

  1. 受信したデータを既存バッファに追加
  2. 完全性チェック:バッファに \r\n が含まれるかを確認
  3. \r\n がない場合、さらなるデータ到着を待機
  4. バッファが 1024 バイトを超える場合、"Request too long" エラーで接続終了

リクエスト長制限

Gemini プロトコル仕様により、リクエストは最大 1024 バイトに制限されます。

パラメータ
MAX_REQUEST_LENGTH 1024
チェック時点 バッファ蓄積中
超過時の処理 ステータス 59 エラー、接続終了

php-gm-server/src/GeminiServer.php:55-58

RequestParser によるリクエスト解析

リクエストの完全性が確認(\r\n 検出)されたら、RequestParser で Gemini URL 形式をパースします。

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

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

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

パース処理の詳細 php-gm-server/src/RequestParser.php:14-47

パース手順

  1. 行の抽出:バッファから最初の \r\n までを抽出

    $line = explode("\r\n", $raw, 2)[0];
  2. 長さ検証:抽出した行が 1024 バイト以内か確認

  3. URL パース:PHP の parse_url() 関数で URL を分解

    $parts = parse_url($line);
  4. スキーム検証:スキームが gemini のみを受け入れ

    if ($scheme !== 'gemini') {
        return null;
    }
  5. ホスト名抽出host フィールドが空でないか確認

  6. パス解析path フィールドを抽出(デフォルト /

    $path = $parts['path'] ?? '/';

パース結果

成功時は以下の連想配列を返却:

[
    'host' => 'example.com',
    'path' => '/path/to/file.gmi'
]

失敗時(スキーム不正、ホスト未指定など)は null を返却。

StaticFileHandler によるファイル処理

パースされたパスに基づき、StaticFileHandler がドキュメントルート内のファイルを検索・取得します。

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

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

ファイルハンドリングのフロー php-gm-server/src/StaticFileHandler.php:35-68

1. ファイルパスの構築

$filePath = $this->docRoot . $path;

ドキュメントルート(例:/var/www/content)と リクエストパス(例:/index.gmi)を連結。

2. ディレクトリハンドリング

if (is_dir($filePath)) {
    $filePath = rtrim($filePath, '/') . '/index.gmi';
}

ディレクトリへのアクセス時は自動的に index.gmi を検索。

3. パストラバーサル防止

$realPath = realpath($filePath);
if ($realPath === false) {
    return null;
}

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

セキュリティメカニズム php-gm-server/src/StaticFileHandler.php:43-50

  • realpath() でシンボリックリンクを解決し、正規化されたパスを取得
  • ドキュメントルートチェック:正規化後のパスがドキュメントルート以下か確認
  • パストラバーサル攻撃(../など)を防止

4. ファイルの存在確認と読み込み

if (!is_file($realPath)) {
    return null;
}

$body = file_get_contents($realPath);
if ($body === false) {
    return null;
}

5. MIME タイプの判定

$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$mime = self::MIME_MAP[$ext] ?? 'application/octet-stream';

return [
    'mime' => $mime,
    'body' => $body,
];

サポートされた MIME タイプ php-gm-server/src/StaticFileHandler.php:11-25

拡張子 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
その他 application/octet-stream

レスポンス構築

ファイルハンドラーから返された MIME タイプとボディに基づき、ResponseBuilder が Gemini プロトコル形式のレスポンスを構築します。

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

成功レスポンス(ステータス 20)

public static function success(string $mime, string $body): string
{
    return "20 {$mime}\r\n{$body}";
}

形式:20 {MIME}\r\n{BODY}

例:

20 text/gemini
# ページタイトル

このはファイルの内容です。

ソース php-gm-server/src/ResponseBuilder.php:9-12

見つからないエラー(ステータス 51)

public static function notFound(): string
{
    return "51 Not found\r\n";
}

形式:51 Not found\r\n

ソース php-gm-server/src/ResponseBuilder.php:14-17

不正リクエストエラー(ステータス 59)

public static function badRequest(string $reason = 'Bad request'): string
{
    return "59 {$reason}\r\n";
}

形式:59 {理由}\r\n

ソース php-gm-server/src/ResponseBuilder.php:19-22

エラーハンドリング

リクエスト処理の各段階でエラーが発生する可能性があります。

リクエスト長超過

バッファが 1024 バイトを超えた場合:

59 Request too long

不正なリクエスト形式

RequestParser::parse() が null を返した場合:

59 Invalid request

ファイル見つからず

StaticFileHandler::handle() が null を返した場合:

51 Not found

接続エラー

接続中に例外が発生した場合、コンソール出力:

Connection error: {例外メッセージ}

php-gm-server/src/GeminiServer.php:45-47

接続は 'error' イベント後も自動的に終了されます。

完全なリクエスト処理フロー図

flowchart TD
    A["クライアント接続"] --> B["TLS接続確立"]
    B --> C["'connection' イベント"]
    C --> D["バッファ初期化"]
    D --> E["'data' イベント受信"]
    E --> F["バッファにデータ追加"]
    F --> G{"\r\n を<br/>検出?"}
    G -->|いいえ| H{"バッファ > <br/>1024byte?"}
    H -->|はい| I["59 Request too long"]
    I --> J["接続終了"]
    H -->|いいえ| K["次のデータ待機"]
    K --> E
    G -->|はい| L["RequestParser::parse"]
    L --> M{"パース<br/>成功?"}
    M -->|失敗| N["59 Invalid request"]
    N --> J
    M -->|成功| O["host と path を抽出"]
    O --> P["StaticFileHandler::handle"]
    P --> Q{"ファイル<br/>存在?"}
    Q -->|いいえ| R["51 Not found"]
    R --> J
    Q -->|はい| S["MIME タイプ判定"]
    S --> T["ResponseBuilder::success"]
    T --> U["レスポンス送信"]
    U --> J
Loading

データフロー(シーケンス図)

sequenceDiagram
    participant Client as クライアント
    participant SS as SecureServer
    participant GS as GeminiServer
    participant RP as RequestParser
    participant SFH as StaticFileHandler
    participant RB as ResponseBuilder

    Client ->> SS: TLS Handshake
    SS ->> GS: connection イベント
    Client ->> SS: データ送信 (part1)
    SS ->> GS: data イベント
    GS ->> GS: バッファに追加
    Client ->> SS: データ送信 (part2 + \r\n)
    SS ->> GS: data イベント
    GS ->> GS: \r\n 検出
    GS ->> RP: parse(buffer)
    RP ->> RP: URL パース
    RP ->> GS: {host, path}
    GS ->> SFH: handle(path)
    SFH ->> SFH: ファイル検索
    SFH ->> SFH: realpath()
    SFH ->> SFH: パストラバーサル防止
    SFH ->> GS: {mime, body}
    GS ->> RB: success(mime, body)
    RB ->> GS: レスポンス文字列
    GS ->> SS: write(response)
    SS ->> Client: レスポンス送信
    GS ->> SS: end()
    SS ->> Client: 接続終了
Loading

バッファ管理のタイムライン

flowchart LR
    T1["時点1: 初期状態<br/>buffer = ''"]
    T2["時点2: データ1受信<br/>buffer = 'gemini://.../'"]
    T3["時点3: データ2受信<br/>buffer = 'gemini://...../r/n'"]
    T4["時点4: \r\n 検出<br/>パース開始"]
    T5["時点5: パース完了<br/>ファイル処理"]
    T6["時点6: レスポンス送信<br/>接続終了"]

    T1 --> T2
    T2 --> T3
    T3 --> T4
    T4 --> T5
    T5 --> T6
Loading

エラーレスポンスの分類

graph TD
    E["エラー発生"]
    E -->|リクエスト検証| E1["リクエスト長超過<br/>形式不正<br/>スキーム不正"]
    E1 --> R1["59 Bad Request"]
    E -->|リソース検索| E2["ファイル未検出<br/>ディレクトリ不正"]
    E2 --> R2["51 Not Found"]
    E -->|システムエラー| E3["接続エラー<br/>ファイル読み込み失敗"]
    E3 --> R3["接続終了"]
Loading

パフォーマンス特性

バッファリング戦略

  • ストリーミング受信:データはバッファに蓄積され、完全なリクエストまで待機
  • 早期終了:1024 バイト超過時は即座に接続を終了(リソース節約)

ファイル処理

  • realpath() のオーバーヘッド:パストラバーサル防止のため全リクエストで実行
  • MIME タイプ判定:拡張子に基づくハッシュテーブル参照(高速)

接続管理

  • TLS ハンドシェイク:ReactPHP により非同期で処理
  • 自動接続終了:レスポンス送信後、conn->end() で接続を閉鎖

セキュリティ考慮事項

リクエスト検証

  • リクエスト長制限:1024 バイト(Gemini 仕様)
  • スキーム検証:gemini のみを許可
  • ホスト名必須チェック

ファイルアクセス制御

  • パストラバーサル防止:realpath() による正規化とドキュメントルート内チェック
  • シンボリックリンク解決:realpath() で物理パスに変換

TLS/SSL

  • 自己署名証明書の使用(TOFU モデル)
  • ピア認証を無効化(サーバー認証のみ)

詳細は セキュリティ設計 を参照してください。

トラブルシューティング

"Request too long" エラーが発生する

クライアントが 1024 バイトを超えるリクエストを送信している可能性があります。Gemini プロトコル仕様に準拠した クライアントを使用してください。

"Invalid request" エラーが発生する

  • リクエストが Gemini URL 形式でない(gemini:// で始まらない)
  • ホスト名が指定されていない
  • URL パースに失敗している

ファイルが見つからないエラー(51)

  • ファイルがドキュメントルート内に存在しない
  • ディレクトリの場合、index.gmi が存在しない
  • パストラバーサル防止機構により、ドキュメントルート外へのアクセスがブロックされている

関連ページ

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