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?

『そのポート、誰が掴んでる?』を 3 OS 共通で返す CLI を Rust で書いた

0
Last updated at Posted at 2026-05-05

きっかけ

npm run dev を叩いたら EADDRINUSE 3000。また Node のゾンビが残っている気がする。macOS なら lsof -nP -iTCP:3000 -sTCP:LISTEN、Linux なら ss -Hlntp "( sport = :3000 )"、Windows なら netstat -ano | findstr :3000 の後に tasklist /FI "PID eq ..." — 3 OS それぞれに「同じことを違う呪文で」訊く必要があります。

SSH した先が Alpine と Ubuntu と macOS だったり、CI のランナーが Windows だったりすると、呪文ごとにフラグの順番まで微妙に違う。手癖にならず、その都度 --help を開きに行く羽目になります。

欲しかったのは 1 本だけ:

  • 3 OS で同じ引数port-finder 3000 が macOS でも Linux でも Windows でも動く
  • 出力も同じ形PORT / PID / COMMAND / USER / ADDRESS の 5 列固定
  • dual-stack を嘘つかない0.0.0.0:3000[::]:3000 は別のソケットなので、2 行で見せる
  • --kill で一撃。見てから別コマンドで kill する手間を省く
  • --json で scriptingjq に流してダッシュボードへ
  • cargo install で入る静的バイナリ。C ライブラリ依存なし

書いてみたら、3 OS で使う データフォーマットが本当にバラバラ で、パーサだけ見れば 3 本の別ツールを作ったのとほぼ同じでした。その「バラバラっぷり」が記事になるレベルだったので、残しておきます。

📦 GitHub: https://github.com/sen-ltd/port-finder

スクリーンショット

使い方

# 単発
$ port-finder 3000

# 複数
$ port-finder 3000 8080 5432

# 範囲
$ port-finder 3000-3100

# 引数なしで「全部見せて」
$ port-finder

# kill まで一撃
$ port-finder 3000 --kill          # SIGTERM
$ port-finder 3000 --force         # SIGKILL

# スクリプトへ
$ port-finder --json 8080 | jq '.listeners[] | {pid, command}'

終了コードは 3 値:

Code 意味
0 1 つ以上マッチ(または引数なしで全件表示)
1 指定したポートに listener が無い
2 引数不正・コマンド失敗・--kill が届かなかった

本題 ─ OS ごとに完全に違うレイアウトを 1 本にまとめる

ここからが楽しいところです。各 OS の「誰が listen してるか」を返す仕組みは、インターフェイスも、文字列フォーマットも、PID への紐づけ方すら違います。port-finder#[cfg(target_os = ...)] で 3 本のバックエンドに分けて、同じ Vec<Listener> にランディングさせます。

1. macOS — lsof -F の「1 行 1 フィールド」形式

macOS では lsof が事実上の標準です。素朴に叩くとこう:

$ lsof -nP -iTCP -sTCP:LISTEN
COMMAND     PID USER   FD   TYPE  DEVICE SIZE/OFF NODE NAME
rapportd   1103   me   12u  IPv4  ...          0t0  TCP *:57768 (LISTEN)
rapportd   1103   me   14u  IPv6  ...          0t0  TCP *:57768 (LISTEN)

この列揃え出力はパースが辛い(COMMAND が Code Helper (Plugin) のように空白込みになるため)ので、-F1 行 1 フィールド にしてもらいます:

$ lsof -nP -iTCP -sTCP:LISTEN -F pcLnt
p1103
crapportd
Lme
f12
tIPv4
n*:57768
f14
tIPv6
n*:57768

各行の頭文字がタグです:

タグ 意味 スコープ
p PID — ここから新しいプロセスブロック process
c コマンド名 process
L ログイン名 process
f fd — ここから新しいファイルブロック file
t ファイル型 (IPv4 / IPv6) file
n 名前 — ソケットなら addr:port file

パーサは「現在の PID / コマンド / ユーザー」と「現在の fd のアドレスファミリ」を状態変数で持ち、n 行が来たら Listener を 1 つ emit します。f が来たらファイル側の状態をリセット、p が来たら全部リセット。普通のプロセス内ステートマシンで書けます。

for raw in input.lines() {
    let (tag, value) = raw.split_at(1);
    match tag {
        "p" => { pid = Some(value.parse()?); command = None; user = None; ipv6 = false; }
        "c" => command = Some(value.into()),
        "L" => user    = Some(value.into()),
        "f" => ipv6    = false,          // 新しい fd は型が来るまで不明
        "t" => ipv6    = value == "IPv6",
        "n" => { /* n<addr:port> を分解して push */ }
        _   => {}
    }
}

dual-stack の罠はここで処理します。lsof は IPv4 と IPv6 の両方の wildcard を *:57768 と同じ文字列で出してきます。t マーカーで IPv4 か IPv6 か分かるので、* をそれぞれ 0.0.0.0[::] に書き戻す。

let address = match addr {
    "*" if ipv6 => "[::]".to_string(),
    "*"         => "0.0.0.0".to_string(),
    other       => other.to_string(),
};

これを怠ると、同一プロセスが本当に 2 つのソケットを持っている事実が見えなくなり、「なんで kill しても EADDRINUSE が消えないんだ…」という夜に繋がります。

2. Linux — /proc/net/tcp の 16 進とバイトオーダー

Linux は外部ツールを呼びません。/proc/net/tcp/proc/net/tcp6 を直接読みます:

  sl  local_address rem_address   st ... uid ... inode
   0: 00000000:0BB8 00000000:0000 0A ...  1000 ... 987654 ...

小さい罠が 3 つ:

罠 1: LISTEN 状態のフィルタ

st カラムの 0ATCP_LISTEN です(include/net/tcp_states.h に定義)。ここで絞らないと ESTABLISHED や TIME_WAIT が混ざります。

罠 2: アドレスは __be32%X で印字したもの

つまり リトルエンディアンのホストではバイトが逆順 に出力されます。例えば 0100007F は 127.0.0.1 です:

  • u32::from_str_radix("0100007F", 16)0x0100007F (ホスト u32 値)
  • .to_le_bytes()[0x7F, 0x00, 0x00, 0x01] ← 元のネットワーク順のバイト列

Ipv4Addr::from(u32) ではなく .to_le_bytes() 経由で配列にしてから Ipv4Addr::from([u8; 4]) に渡すのが肝です:

let word = u32::from_str_radix(addr_hex, 16)?;
Ipv4Addr::from(word.to_le_bytes())     // `Ipv4Addr::from(word)` じゃない

IPv6 も同じで、32 文字を 8 文字ずつ 4 回同じ処理を回し、16 バイトを作って Ipv6Addr::from([u8; 16]) に渡せば終わり。

罠 3: ポートは ntohs 済みで host order

カーネルは IP アドレスは __be32 をそのまま印字しますが、ポートは ntohs(inet->inet_sport) でホスト順に直してから %X で出しています(net/ipv4/tcp_ipv4.cget_tcp4_sock)。なので:

let port = u16::from_str_radix(port_hex, 16)?;   // そのまま

0x0BB8 = 3000。バイトスワップ不要。ここで気を利かせて swap すると、ポート番号が何故か 0xB80B (47115) になって「動かない…」と数十分溶けます。

inode → PID の紐づけ

/proc/net/tcp が返すのはソケットの inode 番号だけ。どのプロセスが持っているかは別途調べる必要があります。方法は /proc/[0-9]+/fd/* を総なめして readlink()socket:[<inode>] 形式のリンク先と突合:

fn scan_proc_sockets() -> HashMap<u64, u32> {
    let mut map = HashMap::new();
    for entry in std::fs::read_dir("/proc")?.flatten() {
        let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else { continue };
        let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { continue };
        for fd in fds.flatten() {
            let Ok(link) = std::fs::read_link(fd.path()) else { continue };
            if let Some(inode) = extract_socket_inode(&link.to_string_lossy()) {
                map.entry(inode).or_insert(pid);
            }
        }
    }
    map
}

プロセス名は /proc/<pid>/comm、ユーザー名は /etc/passwd を自前で舐めて UID を名前に変換。全部 stdlib だけで済みます。sudo なしで動かす場合、他ユーザーの /proc/<pid>/fd を読むと permission denied になるので、所有プロセス以外の inode は見つからない可能性があります。これは lsof も同じ制約。

3. Windows — netstat + tasklist の 2 段構え

Windows には lsof も /proc も無いので、外部コマンド 2 本の出力を組み合わせます:

> netstat -ano -p TCP
  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       964
  TCP    [::]:3000              [::]:0                 LISTENING       12345

罠: IPv6 リテラルのコロン

127.0.0.1:3000[::1]:3000 の両方を同じパーサで扱う必要があります。単純な rsplit(':') だと IPv6 部分が壊れる。ブラケット外の最後のコロンで切る、という書き方になります:

pub fn split_address(s: &str) -> Option<(&str, &str)> {
    let mut depth = 0i32;
    let mut last = None;
    for (i, ch) in s.char_indices() {
        match ch {
            '[' => depth += 1,
            ']' => depth -= 1,
            ':' if depth == 0 => last = Some(i),
            _ => {}
        }
    }
    let i = last?;
    Some((&s[..i], &s[i + 1..]))
}

次に tasklist で PID をプロセス名に変換します:

> tasklist /FO CSV /NH
"System","4","Services","0","136 K"
"node.exe","12345","Console","1","100,032 K"

罠: メモリ欄のカンマ

最後の列 100,032 K にコンマが入っている。素朴な split(',') で切ると、PID 列が 1 つずれて全部壊れます。クォート対応の CSV パーサを書きます:

fn split_csv_row(line: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut in_quotes = false;
    let mut chars = line.chars().peekable();
    while let Some(ch) = chars.next() {
        match ch {
            '"' => {
                if in_quotes && chars.peek() == Some(&'"') {
                    cur.push('"'); chars.next();     // "" はエスケープされたクォート
                } else {
                    in_quotes = !in_quotes;
                }
            }
            ',' if !in_quotes => out.push(std::mem::take(&mut cur)),
            _ => cur.push(ch),
        }
    }
    out.push(cur);
    out
}

20 行のミニ CSV パーサですが、仕様通りの "" エスケープまで対応しておくと、万一別のフィールドにクォートが入っても壊れない。

Cross-platform にテストする戦略

3 バックエンドを作ると、CI で困ります。GitHub Actions の Ubuntu ランナーで macOS のパーサを走らせられない、という素朴な問題。

解決策は単純で、パース関数を pub fn parse(input: &str, ...) で外に出して、固定の文字列フィクスチャに対して呼ぶ。ライブコマンド呼び出しはそれぞれ #[cfg(target_os = "...")] pub fn find(...) で分け、parse からは独立しているので、どの OS にいても 3 本のパーサを全部テストできます。

// src/linux.rs
pub fn parse(tcp: &str, tcp6: &str, ports: &[u16]) -> Result<Vec<TcpEntry>, Error> { ... }

#[cfg(target_os = "linux")]
pub fn find(ports: &[u16]) -> Result<Vec<Listener>, Error> {
    let tcp  = std::fs::read_to_string("/proc/net/tcp").unwrap_or_default();
    let tcp6 = std::fs::read_to_string("/proc/net/tcp6").unwrap_or_default();
    parse(&tcp, &tcp6, ports)
        .map(/* + inode → pid 解決 */)
}

#[cfg(not(target_os = "linux"))]
pub fn find(_: &[u16]) -> Result<Vec<Listener>, Error> { Err(Error::Unsupported) }

テストには実際の /proc/net/tcp / lsof -F / netstat -ano の出力を抜粋したフィクスチャ文字列を用意しました:

const TCP4: &str = "\
   0: 00000000:0BB8 00000000:0000 0A ...  1000 ... 987654 ...
   1: 0100007F:1F90 0100007F:C442 01 ...     0 ... 111111 ...  // ESTABLISHED — スキップ
   2: 0100007F:0050 00000000:0000 0A ...     0 ... 222222 ...
";

#[test]
fn listen_rows_only() {
    let got = parse(TCP4, "", &[]).unwrap();
    assert_eq!(got.len(), 2);    // 01 の ESTABLISHED は除外
    assert_eq!(got[0].port, 3000);
    assert_eq!(got[0].address, "0.0.0.0");
}

これだけで、macOS 開発機でも Linux 専用のヘックス変換ロジックをテストできます。私の場合は実機検証は macOS の ./target/release/port-finder 7000 と、Linux の EC2 インスタンスで cargo test && ./target/release/port-finder、Windows は VirtualBox の Windows 11 VM で同じく、という流れ。

--kill は shell out で済ませる

kill は OS ごとに SIGTERM/SIGKILL の送り方が違いますが、kill(2)libc で叩くほど大仰な話ではありません。Unix は kill -15 <pid>Command で呼ぶ、Windows は taskkill /PID <pid> /F を呼ぶ、これで十分:

pub fn kill_pid(pid: u32, force: bool) -> Result<(), Error> {
    #[cfg(unix)] {
        let sig = if force { "-9" } else { "-15" };
        Command::new("kill").arg(sig).arg(pid.to_string()).status()?;
    }
    #[cfg(windows)] {
        let mut cmd = Command::new("taskkill");
        cmd.arg("/PID").arg(pid.to_string());
        if force { cmd.arg("/F"); }
        cmd.status()?;
    }
    Ok(())
}

port-finder 3000 --kill が呼ばれたら、ユーザーに「何を kill するか」を見せてから、ユニークな PID の集合に対して 1 回ずつ送ります。同じプロセスが複数ポートを握っていても kill は 1 回だけ。

テスト

種類 内容
lib unit (macos) 7 lsof -F パーサ、IPv4/IPv6 の wildcard 書き戻し、fd 境界での状態リセット
lib unit (linux) 10 /proc/net/tcp パーサ、hex デコード、LISTEN フィルタ、inode 抽出、uid 解決
lib unit (windows) 7 netstat 列パーサ、ブラケット付き IPv6、tasklist CSV(コンマ・"" エスケープ)
lib unit (render) 6 テーブル幅計算、USER 列の自動省略、JSON 形状、null ユーザーの扱い
main unit 5 ポート範囲展開、重複デデュープ、不正値の拒否
CLI integration 6 --help / --version / 不正入力 / JSON 出力の wellformed 性

合計 42 tests, 0.3 秒で完走。全部スタティックなフィクスチャとポート範囲のテストなので、ネットワークも /proc もコンテナも不要。

test result: ok. 31 passed (lib)
test result: ok. 5 passed (main)
test result: ok. 6 passed (cli integration)

リリースプロファイル

おなじみの Rust バイナリ絞り:

[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"

依存は clap + serde + serde_json の 3 本だけなので、macOS (arm64) で 488 KBringrustls も入ってないから、これくらい軽くなります。

おわりに

port-finder は「同じ質問を 3 OS で一貫して返す」という、ごく小さなユースケースに特化しています。ですが、その裏で lsof -F の 1 行 1 フィールド、/proc/net/tcp の hex 逆順、netstat の IPv6 ブラケットと tasklist CSV のコンマ、という 3 つの全く違うデータ世界 を通ってきます。

「同じ意味」を持つ情報がこれだけ違う形で保存されているのは OS の歴史そのもので、クロスプラットフォーム CLI を書く醍醐味はここに集約されています。cargo install --path . で入る 488 KB のバイナリが、そのデコーダ 3 本分を全部抱えていると思うと、軽くて地味な道具も少し誇らしい気がします。

次に「port 3000 誰だよ」と叫んだら、ぜひ。

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?