中級者向け No.13

ネットワークスキャナー

LAN内のデバイスをスキャンしてIPアドレス・ホスト名・開放ポートを一覧表示するツール。socketとsubprocessの活用を学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

LAN内のデバイスをスキャンしてIPアドレス・ホスト名・開放ポートを一覧表示するツール。socketとsubprocessの活用を学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。

このアプリは「ネットワーク」カテゴリです。ネットワーク連携は Python の代表的な活用領域で、I/O 待ちと UI 更新の関係や非同期処理の考え方が他の Web API 連携にも直接活きます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

ネットワークスキャナー 実行画面(Windows)。IPレンジ欄と下部ステータスバー・ツール欄は環境情報のためマスク処理しています
実行画面(Windows・一部をマスク処理)
ネットワークスキャナー 実行画面(Linux Mint)。IPレンジ欄と下部ステータスバーは環境情報のためマスク処理しています
実行画面(Linux Mint・一部をマスク処理)

2. 機能一覧

  • ループで生成するボタン群(それぞれ _ping_single()_resolve_host()_traceroute() を実行)
  • 「▶ スキャン開始」ボタン(_toggle_scan())で操作
  • ttk.Treeview による表形式の一覧表示
  • messagebox.showerror()messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「ネットワークスキャナー」・サイズ 820x580 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

追加インストール不要(標準ライブラリのみ使用)
app13.py
import tkinter as tk
from tkinter import ttk, messagebox
import socket
import subprocess
import threading
import ipaddress
import platform
import re
from datetime import datetime


class App13:
    """ネットワークスキャナー"""

    def __init__(self, root):
        self.root = root
        self.root.title("ネットワークスキャナー")
        self.root.geometry("820x580")
        self.root.configure(bg="#f8f9fc")
        self.scanning = False
        self._build_ui()
        self._detect_local_ip()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#e65100", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🔍 ネットワークスキャナー",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        # スキャン設定
        cfg_frame = ttk.LabelFrame(self.root, text="スキャン設定", padding=10)
        cfg_frame.pack(fill=tk.X, padx=8, pady=6)

        tk.Label(cfg_frame, text="IPレンジ:").grid(row=0, column=0, sticky="w")
        self.range_var = tk.StringVar(value="192.168.1.0/24")
        ttk.Entry(cfg_frame, textvariable=self.range_var,
                  width=22, font=("Courier New", 11)).grid(
            row=0, column=1, padx=8, sticky="w")

        tk.Label(cfg_frame, text="タイムアウト(ms):").grid(
            row=0, column=2, sticky="w", padx=(16, 4))
        self.timeout_var = tk.IntVar(value=300)
        ttk.Spinbox(cfg_frame, from_=50, to=2000,
                    textvariable=self.timeout_var, width=6).grid(row=0, column=3)

        tk.Label(cfg_frame, text="スレッド数:").grid(
            row=0, column=4, sticky="w", padx=(16, 4))
        self.threads_var = tk.IntVar(value=50)
        ttk.Spinbox(cfg_frame, from_=1, to=200,
                    textvariable=self.threads_var, width=6).grid(row=0, column=5)

        self.port_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(cfg_frame, text="ポートスキャン",
                        variable=self.port_var).grid(row=0, column=6, padx=16)

        self.scan_btn = ttk.Button(cfg_frame, text="▶ スキャン開始",
                                   command=self._toggle_scan)
        self.scan_btn.grid(row=0, column=7, padx=8)

        # プログレス
        self.progress_var = tk.IntVar(value=0)
        self.progress = ttk.Progressbar(self.root, variable=self.progress_var,
                                         maximum=100)
        self.progress.pack(fill=tk.X, padx=8)

        # 結果テーブル
        result_frame = ttk.LabelFrame(self.root, text="スキャン結果", padding=4)
        result_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=6)
        cols = ("ip", "hostname", "ping", "ports", "status")
        self.tree = ttk.Treeview(result_frame, columns=cols,
                                  show="headings", selectmode="browse")
        for c, h, w in [("ip", "IPアドレス", 130), ("hostname", "ホスト名", 200),
                         ("ping", "応答(ms)", 80), ("ports", "開放ポート", 200),
                         ("status", "状態", 70)]:
            self.tree.heading(c, text=h)
            self.tree.column(c, width=w, minwidth=40)
        v_sb = ttk.Scrollbar(result_frame, command=self.tree.yview)
        self.tree.configure(yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.tag_configure("online", foreground="#27ae60")
        self.tree.tag_configure("offline", foreground="#bbb")

        # 下部: ローカル情報 + ツール
        bottom = tk.Frame(self.root, bg="#f8f9fc")
        bottom.pack(fill=tk.X, padx=8, pady=4)
        self.local_info_var = tk.StringVar(value="")
        tk.Label(bottom, textvariable=self.local_info_var,
                 bg="#f8f9fc", font=("Courier New", 10),
                 fg="#555").pack(side=tk.LEFT)

        tool_frame = tk.Frame(bottom, bg="#f8f9fc")
        tool_frame.pack(side=tk.RIGHT)
        tk.Label(tool_frame, text="ツール: ", bg="#f8f9fc").pack(side=tk.LEFT)
        self.tool_ip_var = tk.StringVar()
        ttk.Entry(tool_frame, textvariable=self.tool_ip_var, width=16).pack(side=tk.LEFT, padx=4)
        for text, cmd in [("Ping", self._ping_single),
                           ("ホスト名解決", self._resolve_host),
                           ("Traceroute", self._traceroute)]:
            ttk.Button(tool_frame, text=text, command=cmd).pack(side=tk.LEFT, padx=2)

        self.status_var = tk.StringVar(value="スキャン待機中")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _detect_local_ip(self):
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            local_ip = s.getsockname()[0]
            s.close()
            # デフォルトレンジをセット
            parts = local_ip.rsplit(".", 1)
            self.range_var.set(f"{parts[0]}.0/24")
            hostname = socket.gethostname()
            self.local_info_var.set(
                f"ホスト: {hostname}  ローカルIP: {local_ip}")
            self.tool_ip_var.set(local_ip)
        except Exception:
            pass

    def _toggle_scan(self):
        if self.scanning:
            self.scanning = False
            self.scan_btn.config(text="▶ スキャン開始")
        else:
            self._start_scan()

    def _start_scan(self):
        try:
            network = ipaddress.ip_network(self.range_var.get().strip(),
                                           strict=False)
        except ValueError as e:
            messagebox.showerror("エラー", f"無効なIPレンジ: {e}")
            return
        self.tree.delete(*self.tree.get_children())
        self.scanning = True
        self.scan_btn.config(text="⏹ 停止")
        hosts = list(network.hosts())
        self.progress_var.set(0)
        threading.Thread(target=self._scan_range,
                         args=(hosts,), daemon=True).start()

    def _scan_range(self, hosts):
        total = len(hosts)
        found = 0
        semaphore = threading.Semaphore(self.threads_var.get())
        results = []
        lock = threading.Lock()

        def scan_host(ip):
            nonlocal found
            with semaphore:
                if not self.scanning:
                    return
                ip_str = str(ip)
                online, ms = self._ping(ip_str)
                if online:
                    hostname = self._get_hostname(ip_str)
                    ports = self._scan_ports(ip_str) if self.port_var.get() else ""
                    with lock:
                        found += 1
                        results.append((ip_str, hostname, ms, ports))
                        self.root.after(0, self._add_result,
                                        ip_str, hostname, ms, ports)

        threads = []
        for i, ip in enumerate(hosts):
            if not self.scanning:
                break
            t = threading.Thread(target=scan_host, args=(ip,), daemon=True)
            t.start()
            threads.append(t)
            pct = int((i + 1) / total * 100)
            self.root.after(0, self.progress_var.set, pct)
            self.root.after(0, self.status_var.set,
                            f"スキャン中: {i+1}/{total}  発見: {found}")

        for t in threads:
            t.join(timeout=2)

        self.root.after(0, self._scan_done, found)

    def _ping(self, ip):
        """pingを実行して(alive, ms)を返す"""
        timeout_s = self.timeout_var.get() / 1000
        try:
            param = "-n" if platform.system().lower() == "windows" else "-c"
            cmd = ["ping", param, "1", "-w" if platform.system().lower() == "windows"
                   else "-W", str(self.timeout_var.get()), ip]
            import time
            t0 = time.time()
            result = subprocess.run(cmd, capture_output=True,
                                     timeout=timeout_s + 1)
            ms = int((time.time() - t0) * 1000)
            alive = result.returncode == 0
            return alive, ms
        except Exception:
            # socketで接続チェック
            try:
                import time
                t0 = time.time()
                s = socket.create_connection((ip, 80),
                                              timeout=timeout_s)
                s.close()
                ms = int((time.time() - t0) * 1000)
                return True, ms
            except Exception:
                return False, 0

    def _get_hostname(self, ip):
        try:
            return socket.gethostbyaddr(ip)[0]
        except Exception:
            return ""

    def _scan_ports(self, ip):
        common_ports = [21, 22, 23, 25, 53, 80, 110, 143, 443,
                        445, 3306, 3389, 5432, 8080, 8443]
        open_ports = []
        for port in common_ports:
            try:
                s = socket.create_connection((ip, port), timeout=0.3)
                s.close()
                open_ports.append(port)
            except Exception:
                pass
        return ", ".join(str(p) for p in open_ports)

    def _add_result(self, ip, hostname, ms, ports):
        self.tree.insert("", "end",
                         values=(ip, hostname, f"{ms} ms", ports, "オンライン"),
                         tags=("online",))

    def _scan_done(self, found):
        self.scanning = False
        self.scan_btn.config(text="▶ スキャン開始")
        self.progress_var.set(100)
        self.status_var.set(
            f"スキャン完了  {found} 台発見  "
            f"({datetime.now().strftime('%H:%M:%S')})")

    def _ping_single(self):
        ip = self.tool_ip_var.get().strip()
        if not ip:
            return
        threading.Thread(target=self._do_ping_single,
                         args=(ip,), daemon=True).start()

    def _do_ping_single(self, ip):
        alive, ms = self._ping(ip)
        msg = f"{ip}: {'応答あり' if alive else '応答なし'} ({ms} ms)"
        self.root.after(0, lambda: messagebox.showinfo("Ping結果", msg))

    def _resolve_host(self):
        target = self.tool_ip_var.get().strip()
        if not target:
            return
        try:
            result = socket.gethostbyaddr(target)
            messagebox.showinfo("ホスト名解決",
                                f"ホスト名: {result[0]}\nエイリアス: {result[1]}\nIP: {result[2]}")
        except Exception as e:
            messagebox.showinfo("ホスト名解決", f"解決できませんでした: {e}")

    def _traceroute(self):
        ip = self.tool_ip_var.get().strip()
        if not ip:
            return
        win = tk.Toplevel(self.root)
        win.title(f"Traceroute — {ip}")
        win.geometry("500x300")
        txt = tk.Text(win, font=("Courier New", 10), bg="#1e1e1e",
                      fg="#ccc")
        txt.pack(fill=tk.BOTH, expand=True)

        def run():
            cmd = (["tracert", ip] if platform.system().lower() == "windows"
                   else ["traceroute", ip])
            try:
                proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                         stderr=subprocess.STDOUT,
                                         text=True)
                for line in proc.stdout:
                    win.after(0, txt.insert, tk.END, line)
            except Exception as e:
                win.after(0, txt.insert, tk.END, f"エラー: {e}")

        threading.Thread(target=run, daemon=True).start()


if __name__ == "__main__":
    root = tk.Tk()
    app = App13(root)
    root.mainloop()

5. コード解説

ネットワークスキャナーのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App13 クラスにアプリの全機能をまとめています(メソッド15個・全297行)。__init__ ではタイトル「ネットワークスキャナー」とウィンドウサイズ 820x580 を設定します。あわせて状態を保持する変数(self.scanning)を初期化し、そのあと _build_ui()_detect_local_ip() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×3・tk.Label×7・ttk.LabelFrame×2・ttk.Entry×2・ttk.Spinbox×2・ttk.Checkbuttonttk.Button(ループで生成)・ttk.Progressbarttk.Treeviewttk.Scrollbartk.Topleveltk.Text で構成しています。見出し付きの枠(LabelFrame)は「スキャン設定」「スキャン結果」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「▶ スキャン開始」ボタン(_toggle_scan())を command= で接続しています。ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ _ping_single()_resolve_host()_traceroute()command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ExceptionValueError を捕捉しています。あわせて messagebox.showerror()messagebox.showinfo() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app13.py と保存します。使うのは datetimeipaddressplatformresocketsubprocessthreadingtimetkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App13 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("ネットワークスキャナー")geometry("820x580")configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×3・tk.Label×7・ttk.LabelFrame×2・ttk.Entry×2・ttk.Spinbox×2・ttk.Checkbuttonttk.Button(ループで生成)・ttk.Progressbarttk.Treeviewttk.Scrollbartk.Topleveltk.Text を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_toggle_scan())につなぎます。ループ生成のボタンは、ラベルと処理(_ping_single()_resolve_host()_traceroute())をペアで並べたデータをループで回し、command= に渡して接続します。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _add_result()(4行)・_scan_done()(7行)・_traceroute()(24行)など を実装します。

  7. 7
    動作確認する

    python app13.py で起動し、各ボタンが反応することを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 結果をファイルに保存する

このコードに保存処理はないため、表示中の数値や結果はアプリを閉じると消えます。テキストファイルへ書き出して、次回起動時に読み込む機能を追加してみましょう。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("820x580") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。スキャンは自分が管理している機器・ネットワークにだけ実行してください。会社や公共 Wi-Fi、他人の回線に向けると規約違反や不正アクセスとみなされることがあります。試すときは 192.168.1.0/29 のような狭いレンジにすると、すぐ終わって確かめやすくなります。

  1. 課題1:進み具合のバーを「調べ終わった数」で進める

    いまの _scan_range() は、スレッドを起こした数だけ progress_var を進めます。_ping() の結果を待たずに数えるので、バーはすぐ 100% になり、そのあとも一覧に結果が増え続けます。調べ終わった台数で進むようにしてください。

    期待結果:狭いレンジでスキャンすると、バーが 100% に届くタイミングと「スキャン完了」の表示がほぼそろう。

    合格条件

    • 一覧に結果が増えている間は、バーが 100% に達していない
    • 帯に出る「スキャン中: n/total」の n も、調べ終わった数で増える
    • 「⏹ 停止」を押すとその場でスキャンが止まり、ボタンの表示が「▶ スキャン開始」に戻る

    ヒント①(どこを触るか): 触るのは _scan_range() の2か所です。内側の scan_host() に調べ終わった数を数える変数を足し、for i, ip in enumerate(hosts): の中でやっている self.progress_var.set の呼び出しをそちらへ移します。
    ヒント②(使うもの): 数え方の手本は同じ関数の found です。nonlocal で宣言し、with lock: の中で1ずつ足せば取りこぼしません。画面の更新は self.root.after(0, self.progress_var.set, pct) の形で、必ずメインスレッドに渡します。
    つまずきやすい点: 応答が無かったホストは if online: のブロックに入りません。数える処理をこの中へ置くと、バーがほとんど進まないままになります。self.scanningFalse になると scan_host() は途中で戻るため、停止したあとの合計は台数に届きません。

  2. 課題2:応答(ms) に ping が返した実測値を出す

    いまの _ping()subprocess.run() の前後で経過時間を測っています。この値には ping コマンドの起動時間も含まれるため、コマンドの出力に並ぶ数字より大きめに出ます。出力から実測値を取り出して表示してください。

    期待結果:手元のルーターを「Ping」ツールで叩くと、表示される ms がコマンドの出力に近い値になる。

    合格条件

    • 数字を取り出せなかったときは、これまでどおり測った経過時間を出す
    • 応答が無いホストは、これまでどおり一覧に出ない
    • 一覧の「応答(ms)」列にも、同じ取り出し方の値が並ぶ

    ヒント①(どこを触るか): 触るのは _ping() の1か所です。subprocess.run(cmd, capture_output=True, ...) の戻り値に出力が入っているので、ms = int((time.time() - t0) * 1000) の前に取り出す処理を足します。ファイル先頭の import re(8行目)はまだどこでも使われていないので、そのまま使える状態です。
    ヒント②(使うもの): 出力は result.stdout にバイト列で入ります。文字列へ直したうえで、re.search()ms の直前の数字を拾うパターンを渡します。表記は環境ごとに違い、Windows の日本語版は「時間 =1ms」・英語版は time=1ms・Linux や macOS は time=1.23 ms の形です。
    つまずきやすい点: Windows の ping は UTF-8 で出力しないことがあります(日本語版は CP932)。decode()errors="ignore" を付けるか、subprocess.run(..., text=True, encoding="cp932", errors="ignore") のように読み方を指定してください。1ms 未満は <1ms と表示されるため、= の後ろだけを探すと取りこぼします。

  3. 課題3:保存機能の追加

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。