毎日聞いてる質問
「postgres は 5432 で立ち上がったのか、それとも docker がまた 5433 に振り直したのか?」。この質問に答えるだけなのに nmap は大げさすぎる。SYN スキャン(root 必要)、OS フィンガープリント、NSE スクリプト — どれも「redis が 6379 で生きてるか死んでるか」を知るのに要らない。
聞きたいのはシンプル: 「この N 個のポートに何かリッスンしてる? Yes / No / 分からない」。自分のマシンで、1024 ポートくらいを、tokio が connect() を投げられるだけ高速に。
400 行の Rust、依存 2 つ。tokio の JoinSet、Semaphore、timeout を実際に組み合わせるちょうどいい題材になった。
🔗 GitHub: https://github.com/sen-ltd/port-scanner
スコープ: 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,443、1-1024、80,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-5 → 1,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 の意味
-
Open —
connect()がOk。3 ウェイハンドシェイクが完了。何かがリッスンしている -
Closed —
connect()が高速に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。
