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 で書く 400 行のポートスキャナ — localhost ファーストの設計思想

0
Last updated at Posted at 2026-05-04

毎日聞いてる質問

「postgres は 5432 で立ち上がったのか、それとも docker がまた 5433 に振り直したのか?」。この質問に答えるだけなのに nmap は大げさすぎる。SYN スキャン(root 必要)、OS フィンガープリント、NSE スクリプト — どれも「redis が 6379 で生きてるか死んでるか」を知るのに要らない。

聞きたいのはシンプル: 「この N 個のポートに何かリッスンしてる? Yes / No / 分からない」。自分のマシンで、1024 ポートくらいを、tokio が connect() を投げられるだけ高速に。

400 行の Rust、依存 2 つ。tokio の JoinSetSemaphoretimeout を実際に組み合わせるちょうどいい題材になった。

🔗 GitHub: https://github.com/sen-ltd/port-scanner

Screenshot

スコープ: localhost をデフォルトにする

ポートスキャナにはイメージの問題がある。他人のマシンをスキャンすると「ちょっと失礼」から「法的にアウト」まで幅広い。デフォルトの挙動でそこに触れたくない。

pub fn gate(host: &str, allow_remote: bool) -> Result<(), String> {
    match classify(host) {
        HostKind::Local => Ok(()),
        HostKind::Remote if allow_remote => Ok(()),
        HostKind::Remote => Err(format!(
            "refusing to scan remote host {host:?} without --allow-remote.\n\
             \n\
             This tool scans 127.0.0.1 by default. Scanning another machine \
             without the owner's consent can be illegal in your jurisdiction.\n\
             If you own {host:?} or have permission, re-run with --allow-remote."
        )),
    }
}

ポート仕様のパースより先にゲートが走る。ループバック以外のターゲットは --allow-remote なしで exit 2。判定は構文的で、DNS 解決はしない。192.168.1.1 のようなプライベートアドレスも remote 扱い — ユーザーがどこの LAN にいるか分からないから。

これは「意図的な摩擦」。--allow-remote を付ければ誰でもスキャンできるが、その一手間が「自分が何をしようとしているか分かっているか?」という確認になる。

ポート仕様のパーサ

80,4431-102480,443,8080-8090 のような入力をパースする:

pub fn parse(spec: &str) -> Result<Vec<u16>, ParseError> {
    if spec.is_empty() {
        return Err(ParseError::Empty);
    }
    let mut out: BTreeSet<u16> = BTreeSet::new();
    for item in spec.split(',') {
        if item.is_empty() {
            return Err(ParseError::EmptyItem);
        }
        match item.split_once('-') {
            None => {
                let n = parse_port(item)?;
                out.insert(n);
            }
            Some((a, b)) => {
                let lo = parse_port(a)?;
                let hi = parse_port(b)?;
                if lo > hi {
                    return Err(ParseError::InvalidRange { lo, hi });
                }
                for p in lo..=hi {
                    out.insert(p);
                }
            }
        }
    }
    Ok(out.into_iter().collect())
}

BTreeSet で重複除去+ソートを一発で得る。443,80,80,1-51,2,3,4,5,80,443。アイテム内のスペースは拒否 — nmap の -p フラグと同じ方針。ポート 0 は bind() のワイルドカードなので拒否。

スキャナ: JoinSet + Semaphore

tokio の核心部分:

pub async fn scan(
    host: &str,
    ports: &[u16],
    concurrency: usize,
    per_port_timeout: Duration,
) -> Vec<PortResult> {
    let sem = Arc::new(Semaphore::new(concurrency.max(1)));
    let host: Arc<str> = Arc::from(host);
    let mut set: JoinSet<PortResult> = JoinSet::new();

    for &port in ports {
        let sem = sem.clone();
        let host = host.clone();
        set.spawn(async move {
            let _permit = sem.acquire_owned().await.expect("semaphore closed");
            probe_one(&host, port, per_port_timeout).await
        });
    }

    let mut results = Vec::with_capacity(ports.len());
    while let Some(joined) = set.join_next().await {
        if let Ok(r) = joined { results.push(r); }
    }
    results.sort_by_key(|r| r.port);
    results
}

3 つのパターンが連携している:

JoinSet — N 個のタスクを spawn して完了順に結果を取得。Vec<JoinHandle> より良いのは、join_next() が投入順ではなく完了順に返す点。

Semaphore — 同時接続数の上限。これがないと 1024 ポートスキャンで connect() を一斉に呼び、macOS のデフォルト ulimit -n(1024 fd)を超えて EMFILE エラーになる。デフォルト 200 permits で syscall をスロットリング。

tokio::time::timeout — 個別ポートのプローブをラップ:

async fn probe_one(host: &str, port: u16, deadline: Duration) -> PortResult {
    let started = Instant::now();
    let target = format!("{host}:{port}");
    let result = timeout(deadline, TcpStream::connect(&target)).await;
    let latency = started.elapsed();
    let status = match result {
        Ok(Ok(_stream)) => Status::Open,
        Ok(Err(_))      => Status::Closed,
        Err(_)          => Status::Filtered,
    };
    PortResult { port, status, latency }
}

ネストした Result に注目: timeout()Result<T, Elapsed> を返し、T が内側の Result。成功した connect()Ok(Ok(stream))、拒否は Ok(Err(io::Error))、タイムアウトは Err(Elapsed)。TCP が教えてくれる 3 つの状態にきれいにマッピングする。

Open / Closed / Filtered の意味

  • Openconnect()Ok。3 ウェイハンドシェイクが完了。何かがリッスンしている
  • Closedconnect() が高速に Err(通常 ECONNREFUSED)。ターゲットの kernel が RST を返した。何もリッスンしていないがホストは生きている
  • Filtered — タイムアウトが先に発火。SYN+ACK も RST も返ってこない。ファイアウォールがパケットをドロップしている可能性が高い

nmap の -sT connect スキャンとまったく同じラベル。filtered は正直に「応答がなく、理由は分からない」と言っている。

テスト: 本物のリスナーに対して

#[tokio::test]
async fn open_port_is_detected() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();
    let results = scan("127.0.0.1", &[port], 10, Duration::from_millis(500)).await;
    assert_eq!(results[0].status, Status::Open);
}

127.0.0.1:0 にバインドすればカーネルが空いているポートを割り当てる。保証された open ポートが手に入る。モックもテストダブルも不要 — 本物の TCP スタックに対してテストしている。

closed のテストは、バインドして即ドロップ:

let (listener, port) = bound_listener().await;
drop(listener);

33 ユニットテスト + 12 統合テスト。統合テストは exit コードの契約をエンドツーエンドで検証: open があれば 0、なければ 1、引数不正やセーフティゲートで 2。

トレードオフ

nmap の代替ではない。意図的に作らなかったもの:

  • SYN スキャン — raw ソケットが必要で root が要る。localhost ファーストのツールで root を要求するのは矛盾
  • OS フィンガープリント — TCP ウィンドウサイズとオプション順序のヒューリスティクス集。週末プロジェクトの範囲外
  • サービスバナー取得connect() 後に数バイト読めば推測できるが、追加のパスとタイムアウトとエッジケースが増える
  • UDP — TCP connect でやる UDP スキャンは意味がない。別の戦略が必要

依存 2 つ(clap + tokio)、400 行、Alpine Docker イメージ 9.6 MB

試してみる

git clone https://github.com/sen-ltd/port-scanner
cd port-scanner
docker build -t port-scanner .
docker run --rm port-scanner 127.0.0.1 22,80,443,5432,6379,8080

Rust がインストール済みなら:

cargo install --path .
port-scanner 127.0.0.1 1-1024 --open-only

午後で作って、毎日のワークフローに溶け込むタイプのツール。


SEN 合同会社100 超ポートフォリオシリーズ #164。

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?