0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tokio セマフォで背圧制御する TCP ポートスキャナーを Rust で作った

0
Last updated at Posted at 2026-05-04

きっかけ

ポートスキャンは一見簡単に見える。ポートに接続して、開いているか確認して、次へ。でも 65,535 ポートを高速にスキャンしようとすると、各接続がタイムアウトまで何秒もかかる可能性があり、逐次スキャンでは耐えられない。並行処理が必要だが、無制限の並行処理は別の問題を生む。

この記事では portscan-rs の構築を通して、tokio セマフォによる背圧パターン、テスト可能性のためのトレイトベース DI、closed と filtered を区別するタイムアウト処理を解説する。

スクリーンショット

📦 GitHub(認可されたホストのみ対象)

作ったもの

Rust の非同期 TCP ポートスキャナー。セマフォで同時接続数を制御し、Connector トレイトで実ネットワークをモックに差し替え可能。依存は claptokio のみ。テスト 27 件。

技術的なポイント

無制限並行処理の問題

// これはやってはいけない
for port in 1..=65535 {
    tokio::spawn(async move {
        try_connect(host, port).await;
    });
}

65,535 タスクが即座に生成される。各タスクが TCP 接続を開く。OS のファイルディスクリプタ上限(Linux のデフォルトでは 1024)に当たり、EMFILE エラーで結果がゴミになる。上限を上げても、数万の SYN パケットが NIC を飽和させるかファイアウォールのレート制限を踏む。

セマフォによる背圧

セマフォが正しいプリミティブ。N 個のパーミットを保持し、接続開始前にパーミットを取得、完了(またはタイムアウト)で解放。全パーミットが取られていれば、次のタスクは空きを待つ:

pub async fn scan<C>(
    host: &str, ports: &[u16], concurrency: usize,
    connector: Arc<C>,
) -> Vec<PortResult>
where C: Connector + 'static,
{
    let sem = Arc::new(Semaphore::new(concurrency.max(1)));
    let mut handles = Vec::with_capacity(ports.len());

    for &port in ports {
        let permit = sem.clone().acquire_owned().await.expect("semaphore closed");
        let host_owned = host.to_string();
        let conn = connector.clone();
        let handle = tokio::spawn(async move {
            let _permit = permit; // drop 時に解放
            let state = conn.connect(host_owned, port).await;
            PortResult { port, state }
        });
        handles.push(handle);
    }
    // 結果を収集してソート
}

acquire_owned()tokio::spawnに置くのがポイント。ループ自体が全パーミット使用中はブロックするので、次のタスクを作りもしない。メモリ使用量は concurrency に比例し、ポート総数には比例しない。

acquire_owned を使う理由:acquire() はセマフォを借用する SemaphorePermit<'a> を返すが、spawn されたタスクは 'static が必要。acquire_owned()Arc 参照を持つ OwnedSemaphorePermit を返す。

タイムアウト処理:closed vs filtered

impl Connector for TokioConnector {
    fn connect(&self, host: String, port: u16) -> ConnectFuture {
        let t = self.timeout;
        Box::pin(async move {
            match tokio::time::timeout(t, TcpStream::connect(&addr)).await {
                Ok(Ok(_)) => PortState::Open,      // 接続成功
                Ok(Err(_)) => PortState::Closed,    // 接続拒否(RST)
                Err(_) => PortState::Filtered,      // タイムアウト
            }
        })
    }
}

tokio::time::timeout が 3 方向の結果をくれる。closed ポートは即座に RST を返すので高速。filtered ポートはファイアウォールが SYN を無言で捨てるのでタイムアウトまで待つ。この区別はユーザーにとって有用。

トレイトベースの DI

スキャンエンジンをテストするために実ネットワークを使いたくない。Connector トレイトで抽象化:

pub trait Connector: Send + Sync {
    fn connect(&self, host: String, port: u16) -> ConnectFuture;
}

テストではスタブを注入:

let stub = Arc::new(StubConnector {
    open_ports: vec![22, 80, 443],
    filtered_ports: vec![8080],
});
let results = scan("fake-host", &[22, 80, 81, 443, 8080], 4, stub).await;
let open: Vec<u16> = results.iter()
    .filter(|r| r.state == PortState::Open)
    .map(|r| r.port).collect();
assert_eq!(open, vec![22, 80, 443]);

実 TCP のテストも書いている。TcpListener::bind("127.0.0.1:0") でポート 0 を指定し、OS に空きポートを選ばせる。ハードコードなし、並列テストとの衝突なし。

ポート指定のパース

8022,80,4431-102422,80-82,3306 を受け付ける純粋関数。ポート 0 は拒否、逆範囲(90-80)はタイポとして拒否、重複はマージ。10 件のテストでカバー。

カラー処理

クレートを使わず手書き。Palette 構造体が起動時に enabled: bool を持ち、各メソッドが ANSI エスケープか空文字列を返す。stdout がターミナルでないとき、NO_COLOR がセットされているとき、--no-color が渡されたときに無効化。

テスト

27 件:ユニット 20(ポートパース 10、スタブスキャン、実 TCP、フォーマット、カラー)+ CLI インテグレーション 7(assert_cmd でヘルプ・バージョン・エラー終了コード)。

テストピラミッドは意図的にボトムヘビー。ロジックの大半が純粋関数。非同期スキャンエンジンはスタブ(ロジック正当性)と実 TcpListener(統合確認)の両方でテスト。

おわりに

localhost で 1024 ポート、並行 200、タイムアウト 200ms のスキャンは約 10ms で完了。closed ポートは即座に RST を返すのでタイムアウトがほぼ発火しない。リモートホストで filtered ポートがある場合は (ports / concurrency) * timeout が上限。

ネットワーキング自体は面白くない。面白いのはセマフォによる背圧パターン、トレイトによるテスト可能性、タイムアウトの三方向結果。これらは非同期 Rust のどのネットワークアプリケーションでも使えるパターンだ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?