きっかけ
ポートスキャンは一見簡単に見える。ポートに接続して、開いているか確認して、次へ。でも 65,535 ポートを高速にスキャンしようとすると、各接続がタイムアウトまで何秒もかかる可能性があり、逐次スキャンでは耐えられない。並行処理が必要だが、無制限の並行処理は別の問題を生む。
この記事では portscan-rs の構築を通して、tokio セマフォによる背圧パターン、テスト可能性のためのトレイトベース DI、closed と filtered を区別するタイムアウト処理を解説する。
📦 GitHub(認可されたホストのみ対象)
作ったもの
Rust の非同期 TCP ポートスキャナー。セマフォで同時接続数を制御し、Connector トレイトで実ネットワークをモックに差し替え可能。依存は clap と tokio のみ。テスト 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 に空きポートを選ばせる。ハードコードなし、並列テストとの衝突なし。
ポート指定のパース
80、22,80,443、1-1024、22,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 のどのネットワークアプリケーションでも使えるパターンだ。
