This commit is contained in:
qsc
2026-06-06 01:57:43 +08:00
parent 2a97e1df17
commit 129dc41208
17 changed files with 2582 additions and 1048 deletions
+34 -1
View File
@@ -1,2 +1,35 @@
# Network-tools
常用的网络调试工具
一个基于 Tkinter 的 Windows 网络调试工具,重构版命名为 **NetPilot**
## 功能
- 网卡配置:读取网卡、IPv4/IPv6、网关、DNS、DHCP、链路速率、接口索引、DHCP 服务器与租约时间。
- 配置模板:保存常用网卡配置、套用模板到表单、删除模板。
- Ping 探测:支持持续/指定次数、间隔、超时、包大小、TTL、禁止分片、本地源 IP 下拉、CIDR/范围/列表批量探活、目标导入、CSV 导出、实时丢包率/平均延迟/抖动/质量统计。
- 端口扫描:支持单端口测试、端口列表/范围、常用端口预设、批量主机/CIDR/IP 段扫描、并发/超时控制、只显示开放端口、服务名识别、可选 Banner 探测、开放端口复制与 CSV 导出。
- 路由追踪:支持设置最大跳数和单跳超时时间。
## 运行
```powershell
python main.py
```
修改网卡配置需要以管理员权限运行程序。
## 打包
直接执行:
```powershell
.\打包.bat
```
打包输出:
```text
dist/NetworkTool.exe
```
配置模板运行时会保存到程序同级目录的 `network_profiles.json`
+108
View File
@@ -0,0 +1,108 @@
import ipaddress
import locale
import re
import subprocess
import sys
from typing import Iterable, List
CREATE_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform.startswith("win") else 0
def preferred_encoding() -> str:
return locale.getpreferredencoding(False) or "utf-8"
def run_hidden(command: List[str], timeout=None) -> subprocess.CompletedProcess:
return subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding=preferred_encoding(),
errors="replace",
timeout=timeout,
creationflags=CREATE_NO_WINDOW,
)
def popen_hidden(command: List[str]) -> subprocess.Popen:
return subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding=preferred_encoding(),
errors="replace",
creationflags=CREATE_NO_WINDOW,
)
def validate_host(value: str) -> str:
host = value.strip()
if not host:
raise ValueError("请输入目标地址或域名")
return host
def validate_ip(value: str, allow_empty: bool = False) -> str:
ip = value.strip()
if allow_empty and not ip:
return ""
try:
ipaddress.ip_address(ip)
except ValueError as exc:
raise ValueError(f"IP 地址无效: {value}") from exc
return ip
def validate_port(value, label: str = "端口") -> int:
try:
port = int(str(value).strip())
except ValueError as exc:
raise ValueError(f"{label}必须是整数") from exc
if port < 1 or port > 65535:
raise ValueError(f"{label}必须在 1-65535 之间")
return port
def parse_ports(text: str) -> List[int]:
ports = set()
for part in re.split(r"[,;\s]+", text.strip()):
part = part.strip()
if not part:
continue
if "-" in part:
start_raw, end_raw = part.split("-", 1)
start = validate_port(start_raw, "起始端口")
end = validate_port(end_raw, "结束端口")
if start > end:
raise ValueError("端口范围的起始值不能大于结束值")
ports.update(range(start, end + 1))
else:
ports.add(validate_port(part))
if not ports:
raise ValueError("请输入至少一个端口")
return sorted(ports)
def prefix_to_netmask(prefix_length) -> str:
if prefix_length in ("", None):
return ""
try:
prefix = int(prefix_length)
return str(ipaddress.IPv4Network(f"0.0.0.0/{prefix}").netmask)
except Exception:
return ""
def iter_hosts(prefix: str, start: int, end: int) -> Iterable[str]:
if not prefix.endswith("."):
raise ValueError("网段前缀必须以点号结尾,例如 192.168.1.")
for value in (start, end):
if value < 1 or value > 254:
raise ValueError("主机号范围必须在 1-254 之间")
if start > end:
raise ValueError("起始主机号不能大于结束主机号")
for host_id in range(start, end + 1):
yield validate_ip(f"{prefix}{host_id}")
+278 -104
View File
@@ -1,129 +1,303 @@
import subprocess
import locale
import json
import os
import re
import tkinter as tk
from tkinter import scrolledtext, messagebox
import subprocess
import sys
from typing import Callable, Optional
import logging
from core.Function.common import preferred_encoding, prefix_to_netmask, run_hidden, validate_ip
OutputCallback = Callable[[str, Optional[str]], None]
class NetworkManager:
"""获取本地网卡详细信息(含 DNS、DHCP)"""
def __init__(self, result_box: scrolledtext.ScrolledText):
self.result_box = result_box
def __init__(self, output: OutputCallback):
self.output = output
self.profiles_file = os.path.join(self._base_dir(), "network_profiles.json")
def get_network_info(self):
# 自动获取系统编码(例如 'cp936' 中文Windows
system_encoding = locale.getpreferredencoding(False)
def _base_dir(self) -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 调用 ipconfig /all
result = subprocess.run(
"ipconfig /all",
shell=True,
capture_output=True,
text=True,
encoding=system_encoding, # 自动根据系统语言选择
errors="ignore" # 忽略解码错误
)
def get_network_info(self) -> list[dict]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
$items = Get-NetIPConfiguration | ForEach-Object {
$alias = $_.InterfaceAlias
$index = $_.InterfaceIndex
$adapter = Get-NetAdapter -InterfaceAlias $alias -ErrorAction SilentlyContinue
$ipif = Get-NetIPInterface -InterfaceAlias $alias -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1
$cim = Get-CimInstance Win32_NetworkAdapterConfiguration -Filter "IPEnabled=True" -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceIndex -eq $index } | Select-Object -First 1
[PSCustomObject]@{
name = $alias
description = $_.InterfaceDescription
mac = if ($adapter) { $adapter.MacAddress } else { "" }
status = if ($adapter) { [string]$adapter.Status } else { "" }
link_speed = if ($adapter) { [string]$adapter.LinkSpeed } else { "" }
interface_index = $index
ipv4 = @($_.IPv4Address | Select-Object -ExpandProperty IPAddress)[0]
ipv6 = @($_.IPv6Address | Select-Object -ExpandProperty IPAddress)
prefix_length = @($_.IPv4Address | Select-Object -ExpandProperty PrefixLength)[0]
gateway = @($_.IPv4DefaultGateway | Select-Object -ExpandProperty NextHop)[0]
dns = @($_.DNSServer.ServerAddresses)
dhcp_server = if ($cim) { $cim.DHCPServer } else { "" }
dhcp_lease_obtained = if ($cim) { [string]$cim.DHCPLeaseObtained } else { "" }
dhcp_lease_expires = if ($cim) { [string]$cim.DHCPLeaseExpires } else { "" }
dhcp_enabled = if ($ipif) { [string]$ipif.Dhcp -eq "Enabled" } else { $false }
}
}
$items | ConvertTo-Json -Depth 5 -Compress
"""
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=15)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip() or "PowerShell 获取网卡信息失败")
output = result.stdout
if not output:
raise RuntimeError("无法获取 ipconfig 输出,请检查系统命令执行权限。")
output = result.stdout.strip()
json_start = min([idx for idx in (output.find("["), output.find("{")) if idx >= 0], default=-1)
if json_start < 0:
return self._get_network_info_from_ipconfig()
# 按块拆分
import re
adapter_blocks = re.split(r"\r?\n(?=\S.*?:)", output)
data = json.loads(output[json_start:])
if isinstance(data, dict):
data = [data]
adapters = []
for block in adapter_blocks:
if not re.search(r"适配器", block):
for item in data:
dns = item.get("dns") or []
if isinstance(dns, str):
dns = [dns]
ipv6 = item.get("ipv6") or []
if isinstance(ipv6, str):
ipv6 = [ipv6]
adapters.append(
{
"name": item.get("name") or "",
"description": item.get("description") or "",
"mac": item.get("mac") or "",
"status": item.get("status") or "",
"link_speed": item.get("link_speed") or "",
"interface_index": item.get("interface_index") or "",
"ipv4": item.get("ipv4") or "",
"ipv6": ipv6,
"prefix_length": item.get("prefix_length") or "",
"netmask": prefix_to_netmask(item.get("prefix_length")),
"gateway": item.get("gateway") or "",
"dns1": dns[0] if len(dns) > 0 else "",
"dns2": dns[1] if len(dns) > 1 else "",
"dhcp_server": item.get("dhcp_server") or "",
"dhcp_lease_obtained": item.get("dhcp_lease_obtained") or "",
"dhcp_lease_expires": item.get("dhcp_lease_expires") or "",
"dhcp_enabled": bool(item.get("dhcp_enabled")),
}
)
return sorted(adapters, key=lambda value: value["name"])
def _get_network_info_from_ipconfig(self) -> list[dict]:
result = run_hidden(["ipconfig", "/all"], timeout=15)
if result.returncode != 0 or not result.stdout.strip():
raise RuntimeError(result.stdout.strip() or "无法读取 ipconfig /all 输出")
adapters = []
current = None
last_key = None
header_pattern = re.compile(r"^(?:\S.* adapter|.+适配器)\s+(.+):$")
unknown_header_pattern = re.compile(r"^(?:Unknown adapter|未知适配器)\s+(.+):$")
value_pattern = re.compile(r"^\s*([^:]+?)\s*:\s*(.*)$")
def push_current():
if current and current.get("name"):
adapters.append(current.copy())
for raw_line in result.stdout.splitlines():
line = raw_line.rstrip()
header = header_pattern.match(line) or unknown_header_pattern.match(line)
if header:
push_current()
current = {
"name": header.group(1).strip(),
"description": "",
"mac": "",
"status": "Disconnected" if "Media disconnected" in line else "",
"link_speed": "",
"interface_index": "",
"ipv4": "",
"ipv6": [],
"prefix_length": "",
"netmask": "",
"gateway": "",
"dns1": "",
"dns2": "",
"dhcp_server": "",
"dhcp_lease_obtained": "",
"dhcp_lease_expires": "",
"dhcp_enabled": False,
}
last_key = None
continue
info = {}
match = re.match(r"(.+?):", block)
if current is None:
continue
if ("Media State" in line and "Media disconnected" in line) or ("媒体状态" in line and "媒体已断开连接" in line):
current["status"] = "Disconnected"
continue
match = value_pattern.match(line)
if match:
info["name"] = match.group(1).strip()
key = match.group(1).replace(".", "").strip()
value = self._clean_ipconfig_value(match.group(2))
last_key = key
self._assign_ipconfig_value(current, key, value)
continue
match = re.search(r"描述.*?:\s*(.+)", block)
if match:
info["description"] = match.group(1).strip()
continuation = line.strip()
if continuation and last_key == "DNS Servers":
self._assign_ipconfig_value(current, last_key, self._clean_ipconfig_value(continuation))
match = re.search(r"物理地址.*?:\s*(.+)", block)
if match:
info["mac"] = match.group(1).strip()
push_current()
return sorted(adapters, key=lambda value: value["name"])
match = re.search(r"DHCP 已启用.*?:\s*(.+)", block)
if match:
value = match.group(1).strip()
info["dhcp_enabled"] = "" if value == "" else ""
def _assign_ipconfig_value(self, adapter: dict, key: str, value: str) -> None:
if key == "Description":
adapter["description"] = value
elif key == "描述":
adapter["description"] = value
elif key == "Physical Address":
adapter["mac"] = value
elif key == "物理地址":
adapter["mac"] = value
elif key == "DHCP Enabled":
adapter["dhcp_enabled"] = value.lower() == "yes" or value == ""
elif key == "DHCP 已启用":
adapter["dhcp_enabled"] = value.lower() == "yes" or value == ""
elif key in ("IPv4 Address", "Autoconfiguration IPv4 Address"):
adapter["ipv4"] = value
if not adapter["status"]:
adapter["status"] = "Up"
elif key in ("IPv4 地址", "自动配置 IPv4 地址"):
adapter["ipv4"] = value
if not adapter["status"]:
adapter["status"] = "Up"
elif key == "Link-local IPv6 Address" or key == "IPv6 Address":
if value:
adapter["ipv6"].append(value)
elif key == "本地链接 IPv6 地址" or key == "IPv6 地址":
if value:
adapter["ipv6"].append(value)
elif key == "Subnet Mask":
adapter["netmask"] = value
elif key == "子网掩码":
adapter["netmask"] = value
elif key == "Default Gateway" and value:
adapter["gateway"] = value
elif key == "默认网关" and value:
adapter["gateway"] = value
elif key == "DNS Servers" and value:
if not adapter["dns1"]:
adapter["dns1"] = value
elif not adapter["dns2"]:
adapter["dns2"] = value
elif key == "DNS 服务器" and value:
if not adapter["dns1"]:
adapter["dns1"] = value
elif not adapter["dns2"]:
adapter["dns2"] = value
elif key == "DHCP Server":
adapter["dhcp_server"] = value
elif key == "DHCP 服务器":
adapter["dhcp_server"] = value
elif key == "Lease Obtained":
adapter["dhcp_lease_obtained"] = value
elif key == "获得租约的时间":
adapter["dhcp_lease_obtained"] = value
elif key == "Lease Expires":
adapter["dhcp_lease_expires"] = value
elif key == "租约过期的时间":
adapter["dhcp_lease_expires"] = value
match = re.search(r"IPv4 地址.*?:\s*([0-9.]+)", block)
if match:
info["ipv4"] = match.group(1)
def _clean_ipconfig_value(self, value: str) -> str:
return re.sub(r"\s*\((?:Preferred|首选)\)\s*$", "", value.strip())
match = re.search(r"子网掩码.*?:\s*([0-9.]+)", block)
if match:
info["netmask"] = match.group(1)
def set_network_info(self, settings: dict) -> None:
name = settings["name"].strip()
if not name:
raise ValueError("请选择网卡")
gateway = ""
# 先找出 "默认网关" 所在行及后续可能的下一行
gw_match = re.search(r"默认网关[.\s:]*([^\r\n]*)\r?\n(?:\s*([^\r\n]+))?", block)
if gw_match:
# 合并两行文本
gw_text = " ".join(gw_match.groups(default=""))
# 提取 IPv4 地址
ipv4_match = re.search(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", gw_text)
if ipv4_match:
gateway = ipv4_match.group(0)
info["gateway"] = gateway
self.output("准备应用网卡配置:\n", "muted")
for key in ("name", "dhcp_enabled", "ipv4", "netmask", "gateway", "dns1", "dns2"):
self.output(f" {key}: {settings.get(key, '')}\n", "muted")
# --- DNS 服务器提取(支持多行 + IPv4优先)---
dns1 = dns2 = ""
# 找出 “DNS 服务器” 开始的位置
dns_match = re.search(r"DNS 服务器[.\s:]*([^\r\n]*)((?:\r?\n\s+[^\r\n]+)*)", block)
if dns_match:
# 合并所有行
dns_text = dns_match.group(1) + dns_match.group(2)
# 提取所有 IPv4 地址(优先),如果没有,再取 IPv6
dns_ipv4 = re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", dns_text)
if dns_ipv4:
dns1 = dns_ipv4[0]
if len(dns_ipv4) > 1:
dns2 = dns_ipv4[1]
else:
# 没有 IPv4,则尝试 IPv6
dns_ipv6 = re.findall(r"[a-fA-F0-9:]+(?:%[0-9]+)?", dns_text)
if dns_ipv6:
dns1 = dns_ipv6[0]
if len(dns_ipv6) > 1:
dns2 = dns_ipv6[1]
if settings.get("dhcp_enabled"):
self._run_netsh(["interface", "ip", "set", "address", f"name={name}", "source=dhcp"])
self._run_netsh(["interface", "ip", "set", "dnsservers", f"name={name}", "source=dhcp"])
self.output("已切换为 DHCP 自动获取\n", "success")
return
info["dns1"] = dns1
info["dns2"] = dns2
ipv4 = validate_ip(settings.get("ipv4", ""))
netmask = validate_ip(settings.get("netmask", ""))
gateway = validate_ip(settings.get("gateway", ""))
self._run_netsh(
[
"interface",
"ip",
"set",
"address",
f"name={name}",
"source=static",
f"addr={ipv4}",
f"mask={netmask}",
f"gateway={gateway}",
]
)
adapters.append(info)
return adapters
dns1 = validate_ip(settings.get("dns1", ""), allow_empty=True)
dns2 = validate_ip(settings.get("dns2", ""), allow_empty=True)
if dns1:
self._run_netsh(["interface", "ip", "set", "dnsservers", f"name={name}", "source=static", f"address={dns1}", "index=1"])
if dns2:
self._run_netsh(["interface", "ip", "add", "dnsservers", f"name={name}", f"address={dns2}", "index=2"])
else:
self._run_netsh(["interface", "ip", "set", "dnsservers", f"name={name}", "source=dhcp"])
def set_network_info(self, settings):
"""设置指定网卡的网络配置"""
# 自动获取系统编码(例如 'cp936' 中文Windows
system_encoding = locale.getpreferredencoding(False)
try:
self.result_box.insert(tk.END, f"尝试将网卡配置修改为:\n")
for key, value in settings.items():
self.result_box.insert(tk.END, f" {key}: {value}\n")
if settings['dhcp_enabled']:
# 启用 DHCP 自动获取 IP 地址
subprocess.run(
f'netsh interface ip set address name="{settings['name']}" source=dhcp',
shell=True, check=True
)
else:
subprocess.run(
f'netsh interface ip set address name="{settings['name']}" source=static addr={settings['ipv4']} mask={settings['netmask']} gateway={settings['gateway']}',
shell=True, check=True)
self.result_box.insert(tk.END, f"网络配置设置完成\n")
self.output("静态 IPv4 配置已应用\n", "success")
except subprocess.CalledProcessError as e:
self.result_box.insert(tk.END, f"设置{settings['name']}网络配置失败: {e}\n")
self.result_box.see(tk.END)
def load_profiles(self) -> dict:
if not os.path.exists(self.profiles_file):
return {}
with open(self.profiles_file, "r", encoding="utf-8") as file:
data = json.load(file)
return data if isinstance(data, dict) else {}
def save_profile(self, profile_name: str, settings: dict) -> None:
name = profile_name.strip()
if not name:
raise ValueError("请输入模板名称")
profiles = self.load_profiles()
profiles[name] = {
"dhcp_enabled": bool(settings.get("dhcp_enabled")),
"ipv4": settings.get("ipv4", ""),
"netmask": settings.get("netmask", ""),
"gateway": settings.get("gateway", ""),
"dns1": settings.get("dns1", ""),
"dns2": settings.get("dns2", ""),
}
self._write_profiles(profiles)
def delete_profile(self, profile_name: str) -> None:
profiles = self.load_profiles()
if profile_name in profiles:
del profiles[profile_name]
self._write_profiles(profiles)
def _write_profiles(self, profiles: dict) -> None:
with open(self.profiles_file, "w", encoding="utf-8") as file:
json.dump(profiles, file, ensure_ascii=False, indent=2)
def _run_netsh(self, args: list[str]) -> None:
command = ["netsh"] + args
result = subprocess.run(command, capture_output=True, text=True, encoding=preferred_encoding(), errors="replace")
if result.returncode != 0:
message = (result.stdout + result.stderr).strip()
raise RuntimeError(message or "netsh 命令执行失败,请确认已用管理员权限运行")
+322 -134
View File
@@ -1,157 +1,345 @@
import tkinter as tk
from tkinter import scrolledtext, messagebox
import subprocess
import concurrent.futures
import threading
import csv
import ipaddress
import re
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Optional
from core.Function.common import run_hidden, validate_host, validate_ip
OutputCallback = Callable[[str, Optional[str]], None]
DoneCallback = Callable[[], None]
StatusCallback = Callable[[dict], None]
@dataclass
class PingStats:
sent: int = 0
received: int = 0
rtts: list[float] = field(default_factory=list)
current_loss_streak: int = 0
max_loss_streak: int = 0
last_success_at: str = ""
@property
def lost(self) -> int:
return max(self.sent - self.received, 0)
@property
def loss_rate(self) -> float:
return self.lost / self.sent * 100 if self.sent else 0.0
@property
def avg_rtt(self) -> float:
return sum(self.rtts) / len(self.rtts) if self.rtts else 0.0
@property
def jitter(self) -> float:
if len(self.rtts) < 2:
return 0.0
deltas = [abs(self.rtts[index] - self.rtts[index - 1]) for index in range(1, len(self.rtts))]
return sum(deltas) / len(deltas)
@property
def quality(self) -> str:
if not self.sent:
return "等待"
if self.loss_rate >= 30 or self.max_loss_streak >= 3:
return "丢包严重"
if self.loss_rate > 0 or self.jitter >= 50:
return "波动"
return "稳定"
def snapshot(self) -> dict:
return {
"sent": self.sent,
"received": self.received,
"lost": self.lost,
"loss_rate": self.loss_rate,
"min_rtt": min(self.rtts) if self.rtts else 0.0,
"max_rtt": max(self.rtts) if self.rtts else 0.0,
"avg_rtt": self.avg_rtt,
"jitter": self.jitter,
"max_loss_streak": self.max_loss_streak,
"last_success_at": self.last_success_at,
"quality": self.quality,
}
class PingFun:
def __init__(self, result_box: scrolledtext.ScrolledText):
self.result_box = result_box
def __init__(
self,
output: OutputCallback,
done: Optional[DoneCallback] = None,
status: Optional[StatusCallback] = None,
):
self.output = output
self.done = done or (lambda: None)
self.status = status or (lambda _stats: None)
self.stop_event = threading.Event()
self.worker = None
self.batch_worker = None
self.stats = PingStats()
self.last_batch_results = []
# Ping 状态和统计
self.process = None
self.stop_flag = False
self.sent = 0
self.received = 0
self.rtts = []
def start_ping(self, host: str, local_ip: str = "", options: Optional[dict] = None) -> None:
host = validate_host(host)
local_ip = validate_ip(local_ip, allow_empty=True)
options = self.normalize_options(options)
def strat_ping(self, host, local_ip=None, callback=None):
"""开始 ping"""
self.callback = callback
self.result_box.delete('1.0', tk.END)
self.sent = 0
self.received = 0
self.rtts.clear()
self.stop_flag = False
if self.is_running() or self.is_batch_running():
raise RuntimeError("Ping 正在运行,请先停止当前任务")
command = ['ping', host, '-t']
if local_ip:
command += ['-S', local_ip]
self.stop_event.clear()
self.stats = PingStats()
self.status(self.stats.snapshot() | {"state": "运行中"})
threading.Thread(target=self.ping, args=(command,), daemon=True).start()
self.output(f"开始 Ping: {host}\n", "muted")
self.output(self.describe_options(options, local_ip), "muted")
self.worker = threading.Thread(target=self._run_ping_loop, args=(host, local_ip, options), daemon=True)
self.worker.start()
def stop_ping(self):
"""手动停止 ping"""
if self.process:
self.process.terminate()
self.result_box.insert(tk.END, "\nPing 已手动停止。\n")
self.result_box.see(tk.END)
self.process = None
self.show_statistics()
def ping(self, command):
"""执行 ping 命令并处理输出"""
rtt_pattern = re.compile(r'时间[=<](\d+)ms', re.IGNORECASE)
def _run_ping_loop(self, host: str, local_ip: str, options: dict) -> None:
max_count = None if options["mode"] == "持续" else options["count"]
try:
# 👇关键:隐藏 CMD 窗口
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform.startswith("win") else 0
self.process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
creationflags=creationflags
)
for line in iter(self.process.stdout.readline, ''):
if self.stop_flag:
break
if line:
self.result_box.insert(tk.END, line)
self.result_box.see(tk.END)
self.sent += 1
match = rtt_pattern.search(line)
if match:
self.received += 1
self.rtts.append(float(match.group(1)))
except Exception as e:
self.result_box.insert(tk.END, f"Ping 失败: {e}\n")
while not self.stop_event.is_set() and (max_count is None or self.stats.sent < max_count):
started = time.perf_counter()
result = self.ping_once(host, local_ip, options)
self._record_single_result(result)
elapsed_ms = (time.perf_counter() - started) * 1000
wait_ms = max(options["interval_ms"] - elapsed_ms, 0)
self.stop_event.wait(wait_ms / 1000)
finally:
self.process = None
if self.callback:
self.callback()
self._write_statistics("Ping 统计")
self.status(self.stats.snapshot() | {"state": "已停止" if self.stop_event.is_set() else "已完成"})
self.done()
def show_statistics(self):
if self.sent == 0:
return
loss = (self.sent - self.received) / self.sent * 100
stats = f"\n==== Ping 统计 ====\n" \
f"发送: {self.sent},接收: {self.received},丢包率: {loss:.2f}%\n"
if self.rtts:
stats += f"最小延迟: {min(self.rtts)} ms,最大延迟: {max(self.rtts)} ms,平均延迟: {sum(self.rtts)/len(self.rtts):.2f} ms\n"
self.result_box.insert(tk.END, stats)
self.result_box.see(tk.END)
def _record_single_result(self, result: dict) -> None:
self.stats.sent += 1
if result["ok"]:
self.stats.received += 1
self.stats.current_loss_streak = 0
self.stats.rtts.append(result["rtt"])
self.stats.last_success_at = time.strftime("%H:%M:%S")
self.output(f"[{self.stats.sent}] {result['host']}{result['rtt']:.1f} ms\n", "success")
else:
self.stats.current_loss_streak += 1
self.stats.max_loss_streak = max(self.stats.max_loss_streak, self.stats.current_loss_streak)
self.output(f"[{self.stats.sent}] {result['host']} {result['message']}\n", "warning")
self.status(self.stats.snapshot() | {"state": "运行中"})
# ================= 批量 Ping =================
def start_batch_ping(self, net_prefix, start, end, local_ip=None, callback=None):
self.callback = callback
self.stop_flag = False
self.result_box.delete('1.0', tk.END)
self.result_box.insert(tk.END, f"开始并发 Ping{net_prefix}{start} - {net_prefix}{end}\n\n")
def stop_ping(self) -> None:
self.stop_event.set()
self.output("\n已请求停止 Ping\n", "warning")
self.batch_thread = threading.Thread(
target=self._concurrent_batch_ping, args=(net_prefix, start, end, local_ip), daemon=True)
self.batch_thread.start()
def start_batch_ping(self, target_text: str, local_ip: str = "", options: Optional[dict] = None) -> None:
local_ip = validate_ip(local_ip, allow_empty=True)
hosts = parse_ping_targets(target_text)
options = self.normalize_options(options)
def _ping_one_ip(self, ip, local_ip=None):
"""Ping 单个 IP 地址并返回结果"""
if self.stop_flag:
return None
if self.is_running() or self.is_batch_running():
raise RuntimeError("Ping 正在运行,请先停止当前任务")
command = ['ping', ip, '-n', '1', '-w', '2000']
if local_ip:
command += ['-S', local_ip]
self.stop_event.clear()
self.last_batch_results = []
self.status({"state": "批量运行中", "sent": 0, "received": 0, "lost": 0, "loss_rate": 0, "avg_rtt": 0, "jitter": 0, "quality": "等待"})
self.output(f"开始批量 Ping: 共 {len(hosts)} 个目标\n", "muted")
self.output(self.describe_options(options, local_ip), "muted")
self.batch_worker = threading.Thread(target=self._run_batch_ping, args=(hosts, local_ip, options), daemon=True)
self.batch_worker.start()
def _run_batch_ping(self, hosts: list[str], local_ip: str, options: dict) -> None:
results = []
done = 0
workers = min(options["workers"], max(1, len(hosts)))
def task(host: str) -> dict:
if self.stop_event.is_set():
return {"host": host, "ok": False, "rtt": 0.0, "message": "已取消"}
return self.ping_once(host, local_ip, options)
try:
# 👇 同样隐藏 CMD 窗口
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform.startswith("win") else 0
result = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=2,
creationflags=creationflags # ✅ 加上
)
if "TTL=" in result.stdout.upper():
return f"{ip} ✅ 通\n"
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_host = {executor.submit(task, host): host for host in hosts}
for future in concurrent.futures.as_completed(future_to_host):
if self.stop_event.is_set():
for item in future_to_host:
item.cancel()
break
result = future.result()
done += 1
results.append(result)
tag = "success" if result["ok"] else None
message = f"{result['rtt']:.1f} ms" if result["ok"] else result["message"]
self.output(f"[{done}/{len(hosts)}] {result['host']:<15} {message}\n", tag)
self.status(self._batch_snapshot(results, len(hosts)) | {"state": "批量运行中"})
finally:
ordered = sorted(results, key=lambda item: ipaddress.ip_address(item["host"]))
self.last_batch_results = ordered
if self.stop_event.is_set():
self.output("\n批量 Ping 已停止\n", "warning")
else:
return f"{ip} ❌ 不通\n"
except subprocess.TimeoutExpired:
return f"{ip} ⚠️ 超时\n"
except Exception as e:
return f"{ip} 错误: {e}\n"
self._write_batch_summary(ordered, len(hosts))
self.status(self._batch_snapshot(ordered, len(hosts)) | {"state": "已停止" if self.stop_event.is_set() else "已完成"})
self.done()
def _concurrent_batch_ping(self, net_prefix, start, end, local_ip=None):
'''并发批量 Ping'''
ip_list = [f"{net_prefix}{i}" for i in range(start, end + 1)]
max_workers = min(50, len(ip_list))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_ip = {executor.submit(self._ping_one_ip, ip, local_ip): ip for ip in ip_list}
for future in concurrent.futures.as_completed(future_to_ip):
if self.stop_flag:
break
result = future.result()
if result:
self.result_box.insert(tk.END, result)
self.result_box.see(tk.END)
def stop_batch_ping(self) -> None:
self.stop_event.set()
self.output("\n正在停止批量 Ping...\n", "warning")
if not self.stop_flag:
self.result_box.insert(tk.END, "\n并发批量 Ping 完成。\n")
else:
self.result_box.insert(tk.END, "\n批量 Ping 已停止。\n")
self.result_box.see(tk.END)
if self.callback:
self.callback()
def ping_once(self, host: str, local_ip: str, options: dict) -> dict:
command = ["ping", host, "-n", "1", "-w", str(options["timeout_ms"])]
if local_ip:
command += ["-S", local_ip]
if options["size"] > 0:
command += ["-l", str(options["size"])]
if options["ttl"] > 0:
command += ["-i", str(options["ttl"])]
if options["dont_fragment"]:
command.append("-f")
def stop_batch_ping(self):
if not self.batch_thread or not self.batch_thread.is_alive():
messagebox.showinfo("提示", "当前没有正在运行的批量 Ping。")
try:
result = run_hidden(command, timeout=max(2, options["timeout_ms"] / 1000 + 2))
return parse_ping_output(host, result.stdout)
except Exception as exc:
return {"host": host, "ok": False, "rtt": 0.0, "message": str(exc)}
def normalize_options(self, options: Optional[dict]) -> dict:
options = options or {}
return {
"mode": options.get("mode", "持续"),
"count": clamp_int(options.get("count", 4), 1, 100000, "次数"),
"interval_ms": clamp_int(options.get("interval_ms", 1000), 100, 60000, "间隔"),
"timeout_ms": clamp_int(options.get("timeout_ms", 1200), 100, 60000, "超时"),
"size": clamp_int(options.get("size", 32), 0, 65500, "包大小"),
"ttl": clamp_int(options.get("ttl", 0), 0, 255, "TTL"),
"dont_fragment": bool(options.get("dont_fragment", False)),
"workers": clamp_int(options.get("workers", 64), 1, 256, "并发数"),
}
def describe_options(self, options: dict, local_ip: str) -> str:
mode = "持续" if options["mode"] == "持续" else f"{options['count']}"
source = local_ip or "默认路由"
df = "" if options["dont_fragment"] else ""
ttl = options["ttl"] if options["ttl"] else "默认"
return (
f"模式: {mode} 源地址: {source} 间隔: {options['interval_ms']}ms "
f"超时: {options['timeout_ms']}ms 包大小: {options['size']} bytes TTL: {ttl} 禁止分片: {df}\n\n"
)
def _write_statistics(self, title: str) -> None:
snapshot = self.stats.snapshot()
if not snapshot["sent"]:
return
self.stop_flag = True
self.result_box.insert(tk.END, "\n正在尝试停止批量 Ping...\n")
self.result_box.see(tk.END)
lines = [
f"\n==== {title} ====\n",
f"发送: {snapshot['sent']} 接收: {snapshot['received']} 丢失: {snapshot['lost']} 丢包率: {snapshot['loss_rate']:.1f}%\n",
f"延迟: 最小 {snapshot['min_rtt']:.1f} ms 最大 {snapshot['max_rtt']:.1f} ms 平均 {snapshot['avg_rtt']:.1f} ms 抖动 {snapshot['jitter']:.1f} ms\n",
f"最大连续丢包: {snapshot['max_loss_streak']} 最后成功: {snapshot['last_success_at'] or '-'} 状态: {snapshot['quality']}\n",
]
self.output("".join(lines), "success" if snapshot["loss_rate"] == 0 else "warning")
def _write_batch_summary(self, results: list[dict], total: int) -> None:
online = [item for item in results if item["ok"]]
offline = [item for item in results if not item["ok"] and item["message"] != "超时"]
timeout = [item for item in results if item["message"] == "超时"]
snapshot = self._batch_snapshot(results, total)
self.output("\n==== 批量 Ping 统计 ====\n", "muted")
self.output(f"总数: {total} 在线: {len(online)} 离线: {len(offline)} 超时: {len(timeout)} 成功率: {100 - snapshot['loss_rate']:.1f}%\n", "success")
if online:
self.output("在线 IP: " + ", ".join(item["host"] for item in online) + "\n", "success")
if timeout:
self.output("超时 IP: " + ", ".join(item["host"] for item in timeout) + "\n", "warning")
if offline:
self.output("离线 IP: " + ", ".join(item["host"] for item in offline) + "\n", "warning")
def _batch_snapshot(self, results: list[dict], total: int) -> dict:
received = len([item for item in results if item["ok"]])
rtts = [item["rtt"] for item in results if item["ok"]]
sent = len(results)
loss_rate = (sent - received) / sent * 100 if sent else 0.0
avg = sum(rtts) / len(rtts) if rtts else 0.0
jitter = 0.0
if len(rtts) > 1:
deltas = [abs(rtts[index] - rtts[index - 1]) for index in range(1, len(rtts))]
jitter = sum(deltas) / len(deltas)
return {
"sent": sent,
"received": received,
"lost": max(sent - received, 0),
"loss_rate": loss_rate,
"avg_rtt": avg,
"jitter": jitter,
"quality": "稳定" if loss_rate == 0 else "波动" if loss_rate < 30 else "丢包严重",
}
def export_batch_results(self, path: str) -> None:
if not self.last_batch_results:
raise RuntimeError("还没有可导出的批量 Ping 结果")
with open(path, "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=["host", "ok", "rtt", "message"])
writer.writeheader()
writer.writerows(self.last_batch_results)
def is_running(self) -> bool:
return bool(self.worker and self.worker.is_alive())
def is_batch_running(self) -> bool:
return bool(self.batch_worker and self.batch_worker.is_alive())
def parse_ping_output(host: str, output: str) -> dict:
ttl_found = re.search(r"\bTTL=", output, re.IGNORECASE)
rtt_match = re.search(r"(?:time|时间)[=<]?\s*(\d+(?:\.\d+)?)\s*ms", output, re.IGNORECASE)
if ttl_found:
rtt = float(rtt_match.group(1)) if rtt_match else 0.0
return {"host": host, "ok": True, "rtt": rtt, "message": "在线"}
if re.search(r"请求超时|timed out|timeout", output, re.IGNORECASE):
return {"host": host, "ok": False, "rtt": 0.0, "message": "超时"}
if re.search(r"无法访问|unreachable|could not find host|找不到主机", output, re.IGNORECASE):
return {"host": host, "ok": False, "rtt": 0.0, "message": "不可达"}
return {"host": host, "ok": False, "rtt": 0.0, "message": "无响应"}
def parse_ping_targets(text: str) -> list[str]:
raw = text.strip()
if not raw:
raise ValueError("请输入批量 Ping 目标")
targets = []
for part in re.split(r"[,\s]+", raw):
item = part.strip()
if not item:
continue
if "/" in item:
network = ipaddress.ip_network(item, strict=False)
targets.extend(str(ip) for ip in network.hosts())
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}$", item):
prefix, end_text = item.rsplit(".", 1)
start_text, end_host = end_text.split("-", 1)
start = int(start_text)
end = int(end_host)
if start > end:
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(f"{prefix}.{index}")) for index in range(start, end + 1))
else:
targets.append(str(ipaddress.ip_address(item)))
unique = list(dict.fromkeys(targets))
if not unique:
raise ValueError("没有解析到有效目标")
return unique
def clamp_int(value, min_value: int, max_value: int, label: str) -> int:
try:
number = int(str(value).strip())
except ValueError as exc:
raise ValueError(f"{label}必须是整数") from exc
if number < min_value or number > max_value:
raise ValueError(f"{label}必须在 {min_value}-{max_value} 之间")
return number
+550 -220
View File
@@ -1,251 +1,581 @@
import concurrent.futures
import csv
import ipaddress
import re
import socket
import threading
import concurrent.futures
import tkinter as tk
from tkinter import scrolledtext, messagebox
from typing import Iterable, Optional, List
import time
from dataclasses import asdict, dataclass, field
from typing import Callable, Iterable, Optional
from core.Function.common import parse_ports, validate_host, validate_port
OutputCallback = Callable[[str, Optional[str]], None]
DoneCallback = Callable[[], None]
StatusCallback = Callable[[dict], None]
ResultCallback = Callable[[dict], None]
STATUS_TEXT = {
"open": "开放",
"closed": "关闭",
"timeout": "超时",
"unreachable": "不可达",
"dns_error": "解析失败",
"cancelled": "已取消",
"error": "错误",
}
COMMON_SERVICES = {
20: "FTP-DATA",
21: "FTP",
22: "SSH",
23: "TELNET",
25: "SMTP",
53: "DNS",
67: "DHCP",
68: "DHCP",
80: "HTTP",
110: "POP3",
123: "NTP",
135: "MSRPC",
137: "NETBIOS",
138: "NETBIOS",
139: "NETBIOS",
143: "IMAP",
389: "LDAP",
443: "HTTPS",
445: "SMB",
465: "SMTPS",
587: "SMTP",
636: "LDAPS",
993: "IMAPS",
995: "POP3S",
1433: "MSSQL",
1521: "ORACLE",
3306: "MYSQL",
3389: "RDP",
5432: "POSTGRES",
5900: "VNC",
5985: "WINRM",
5986: "WINRM-SSL",
6379: "REDIS",
8000: "HTTP-ALT",
8080: "HTTP-ALT",
8443: "HTTPS-ALT",
9200: "ELASTIC",
27017: "MONGODB",
}
HTTP_BANNER_PORTS = {80, 8000, 8008, 8080, 8081, 8888, 9000}
@dataclass
class ScanOptions:
timeout_ms: int = 800
workers: int = 128
show_closed: bool = False
banner_probe: bool = False
@dataclass
class PortResult:
host: str
resolved_ip: str
ip_version: str
port: int
service: str
status: str
status_text: str
latency_ms: float = 0.0
banner: str = ""
error: str = ""
checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
def to_dict(self) -> dict:
data = asdict(self)
data["latency_ms"] = round(self.latency_ms, 1)
return data
@dataclass
class ScanStats:
total: int = 0
scanned: int = 0
open_count: int = 0
closed_count: int = 0
timeout_count: int = 0
unreachable_count: int = 0
dns_error_count: int = 0
error_count: int = 0
cancelled_count: int = 0
started_at: float = field(default_factory=time.perf_counter)
def record(self, status: str, count: int = 1) -> None:
self.scanned += count
if status == "open":
self.open_count += count
elif status == "closed":
self.closed_count += count
elif status == "timeout":
self.timeout_count += count
elif status == "unreachable":
self.unreachable_count += count
elif status == "dns_error":
self.dns_error_count += count
elif status == "cancelled":
self.cancelled_count += count
else:
self.error_count += count
def snapshot(self, state: str) -> dict:
elapsed = time.perf_counter() - self.started_at
progress = self.scanned / self.total * 100 if self.total else 0.0
return {
"state": state,
"total": self.total,
"scanned": self.scanned,
"open": self.open_count,
"closed": self.closed_count,
"timeout": self.timeout_count,
"unreachable": self.unreachable_count,
"dns_error": self.dns_error_count,
"error": self.error_count,
"cancelled": self.cancelled_count,
"progress": progress,
"elapsed": elapsed,
}
class PortScanner:
"""
PortScanner: 在 Tkinter 的 ScrolledText 中显示端口检测结果的工具类。
def __init__(
self,
output: OutputCallback,
done: Optional[DoneCallback] = None,
status: Optional[StatusCallback] = None,
result: Optional[ResultCallback] = None,
):
self.output = output
self.done = done or (lambda: None)
self.status = status or (lambda _stats: None)
self.result = result or (lambda _result: None)
self.stop_event = threading.Event()
self.scan_thread = None
self.executor = None
self.last_results: list[dict] = []
构造:
scanner = PortScanner(result_box)
def test_connect(self, host: str, port: int, timeout: float = 1.0, options: Optional[dict] = None) -> None:
options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000), "workers": 1, "show_closed": True})
host = validate_host(host)
port = validate_port(port)
self._start_scan([host], [port], options, "单端口测试")
方法:
test_connect(ip, port, timeout=1.0)
- 直接测试单个 ip:port 是否可连通(立即返回 bool,并在结果框显示一行结果)
def start_text_scan(self, host: str, ports_text: str, timeout: float = 0.8, options: Optional[dict] = None) -> None:
options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000)})
self.start_list_scan(host, parse_ports(ports_text), timeout=timeout, options=options)
start_range_scan(ip, start_port, end_port, timeout=1.0, max_workers=100)
- 并发扫描端口范围 [start_port, end_port](包含端口边界)
- 扫描结果会实时写入 result_box
def start_range_scan(self, host: str, start_port: int, end_port: int, timeout: float = 0.8, options: Optional[dict] = None) -> None:
start_port = validate_port(start_port, "起始端口")
end_port = validate_port(end_port, "结束端口")
if start_port > end_port:
raise ValueError("起始端口不能大于结束端口")
options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000)})
self.start_list_scan(host, range(start_port, end_port + 1), timeout=timeout, options=options)
start_list_scan(ip, ports: Iterable[int], timeout=1.0, max_workers=100)
- 并发扫描给定端口列表
def start_list_scan(
self,
host: str,
ports: Iterable[int],
timeout: float = 0.8,
options: Optional[dict] = None,
) -> None:
host = validate_host(host)
ports = normalize_ports(ports)
options = self.normalize_options(options or {"timeout_ms": int(timeout * 1000)})
self._start_scan([host], ports, options, "端口扫描")
stop_scan()
- 尝试中止正在进行的扫描(设置停止标志,后续任务检测到后会停止提交或返回)
def start_scan(self, host: str, ports_text: str, options: Optional[dict] = None) -> None:
host = validate_host(host)
ports = parse_ports(ports_text)
self._start_scan([host], ports, self.normalize_options(options), "端口扫描")
注意:
- 使用 TCP 连接测试(socket.connect),适合服务端口检测。
- GUI 写入通过 self._append_text(...) 调度到主线程,保证线程安全。
"""
def start_batch_scan(self, hosts_text: str, ports_text: str, options: Optional[dict] = None) -> None:
hosts = parse_scan_hosts(hosts_text)
ports = parse_ports(ports_text)
self._start_scan(hosts, ports, self.normalize_options(options), "批量主机扫描")
def __init__(self, result_box: scrolledtext.ScrolledText):
self.result_box = result_box
def _start_scan(self, hosts: list[str], ports: list[int], options: ScanOptions, title: str) -> None:
if self.is_scanning():
raise RuntimeError("端口扫描正在运行,请先停止当前任务")
if not ports:
raise ValueError("请输入至少一个端口")
# 扫描控制状态
self._stop_flag = False # 外部调用 stop_scan() 会把此标志设为 True
self._scan_thread: Optional[threading.Thread] = None
self._executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
self.stop_event.clear()
self.last_results = []
total = len(hosts) * len(ports)
self.output(f"开始{title}: {len(hosts)} 个目标,{len(ports)} 个端口,共 {total} 次连接\n", "muted")
self.output(self.describe_options(options), "muted")
self.status(ScanStats(total=total).snapshot("运行中"))
self.scan_thread = threading.Thread(target=self._scan, args=(hosts, ports, options, title), daemon=True)
self.scan_thread.start()
# 用于统计(可选)
self._total = 0
self._done = 0
self._open_ports: List[int] = []
# -------------------------
# 辅助方法:线程安全地向结果框写文本
# -------------------------
def _append_text(self, text: str):
"""
把文本插入到 result_box。因为可能从子线程调用,所以用 after 调度到主线程执行。
"""
try:
# schedule on main thread immediately
self.result_box.after(0, lambda: (self.result_box.insert(tk.END, text), self.result_box.see(tk.END)))
except Exception:
# 在极少数情况下(例如 result_box 已销毁),捕获异常避免崩溃
pass
# -------------------------
# 单端口测试
# -------------------------
def test_connect(self, ip: str, port: int, timeout: float = 1.0) -> bool:
"""
立即测试单个 ip:port 是否可以 TCP 连接。
- 返回 True(可连通)或 False(不可连通)
- 同时把结果写入 result_box(通过主线程调度)
"""
addr = (ip, int(port))
status = False
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect(addr)
status = True
sock.close()
except Exception:
status = False
# 输出结果(在 GUI 中显示)
self._append_text(f"{ip}:{port} {'✅ 开放' if status else '❌ 关闭/不可达'}\n")
return status
# -------------------------
# 并发单端口任务(内部使用)
# -------------------------
def _scan_single_port(self, ip: str, port: int, timeout: float) -> str:
"""
线程池中运行的单个端口检测任务,返回一行结果字符串。
任务必须尽量短小(快速返回),并在开始前检查 stop_flag。
"""
if self._stop_flag:
return "" # 为空表示不输出
def _scan(self, hosts: list[str], ports: list[int], options: ScanOptions, title: str) -> None:
stats = ScanStats(total=len(hosts) * len(ports))
resolved_hosts = []
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((ip, port))
sock.close()
result = f"{ip}:{port} ✅ 开放\n"
# 记录到本地开放端口列表(线程安全地追加)
self._open_ports.append(port)
except Exception:
result = f"{ip}:{port} ❌ 关闭/不可达\n"
for host in hosts:
if self.stop_event.is_set():
break
info = resolve_host(host)
if info["ok"]:
resolved_hosts.append(info)
suffix = "" if info["resolved_ip"] == host else f" -> {info['resolved_ip']}"
self.output(f"解析: {host}{suffix} ({info['ip_version']})\n", "muted")
else:
self._record_host_error(host, info["error"], len(ports), stats)
# 更新进度计数(最好在主线程更新显示,这里在子线程更新计数)
self._done += 1
return result
# -------------------------
# 并发扫描(范围或列表)
# -------------------------
def start_range_scan(self, ip: str, start_port: int, end_port: int, timeout: float = 0.8, max_workers: int = 200):
"""
并发扫描端口范围 [start_port, end_port](含两端)。
结果实时写入 result_box。单次扫描在后台线程中运行(不会阻塞主线程)。
"""
# 参数校验(基本)
try:
start_port = int(start_port); end_port = int(end_port)
except Exception:
messagebox.showwarning("输入错误", "起始端口和结束端口必须为整数")
return
if start_port < 1 or end_port > 65535 or start_port > end_port:
messagebox.showwarning("输入错误", "端口范围不合法(1-65535 且 起始<=结束)")
return
ports = list(range(start_port, end_port + 1))
self.start_list_scan(ip, ports, timeout=timeout, max_workers=max_workers)
def start_list_scan(self, ip: str, ports: Iterable[int], timeout: float = 0.8, max_workers: int = 200):
"""
并发扫描指定的端口列表。
- ip: 目标 IP(字符串)
- ports: 可迭代的端口集合(如 list、range 等)
- timeout: 单端口连接超时(秒)
- max_workers: 最大并发数(线程池大小)
"""
# 防止重复启动
if self._scan_thread and self._scan_thread.is_alive():
messagebox.showinfo("提示", "已有扫描任务在运行,请先停止后再启动新的扫描。")
return
# 将 ports 转为列表并进行基本校验
try:
ports_list = [int(p) for p in ports]
except Exception:
messagebox.showwarning("输入错误", "端口列表包含非法值")
return
if not ports_list:
messagebox.showwarning("输入错误", "端口列表为空")
return
for p in ports_list:
if p < 1 or p > 65535:
messagebox.showwarning("输入错误", f"端口 {p} 不在合法范围 1-65535")
return
# 重置状态
self._stop_flag = False
self._open_ports = []
self._total = len(ports_list)
self._done = 0
# 清空 result_box 并输出起始信息(主线程调度)
self._append_text(f"开始并发端口扫描:目标 {ip},共 {self._total} 个端口\n")
# 后台线程用于管理线程池与结果收集,确保 GUI 不阻塞
def manager():
# 根据任务数自适应限制并发数
actual_workers = max(1, min(max_workers, self._total))
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers)
# 提交任务
futures = {self._executor.submit(self._scan_single_port, ip, port, timeout): port for port in ports_list}
try:
# as_completed 会在每个 future 完成时迭代返回
for fut in concurrent.futures.as_completed(futures):
if self._stop_flag:
# 如果外部发出停止信号,尽量取消未开始的 future(cancel 返回 True 表示取消成功)
# 线程池中的任务可能已在运行, cancel 只能取消未开始的任务
break
workers = min(options.workers, max(1, stats.total))
pair_iter = iter((host_info, port) for host_info in resolved_hosts for port in ports)
futures = {}
exhausted = False
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=workers)
while not self.stop_event.is_set() and (futures or not exhausted):
while not self.stop_event.is_set() and not exhausted and len(futures) < workers * 2:
try:
line = fut.result()
except Exception as e:
line = f"{ip}:{futures.get(fut)} 错误: {e}\n"
host_info, port = next(pair_iter)
except StopIteration:
exhausted = True
break
futures[self.executor.submit(self._scan_port, host_info, port, options)] = (host_info, port)
if line:
# 把结果写回 GUI(通过 _append_text 安全调度)
self._append_text(line)
if not futures:
break
# 可选:显示进度(例如:done/total)
self._append_text(f"进度: {self._done}/{self._total}\n")
done, _pending = concurrent.futures.wait(
futures,
timeout=0.15,
return_when=concurrent.futures.FIRST_COMPLETED,
)
for future in done:
context = futures.pop(future, None)
try:
item = future.result()
except Exception as exc:
host_info, port = context or ({"host": "", "resolved_ip": "", "ip_version": ""}, 0)
item = PortResult(
host=host_info["host"],
resolved_ip=host_info["resolved_ip"],
ip_version=host_info["ip_version"],
port=port,
service=service_name(port),
status="error",
status_text=STATUS_TEXT["error"],
error=str(exc),
)
self._record_result(item, stats, options)
# 如果 stop_flag 已设,尝试取消尚未开始的任务
if self._stop_flag:
for f in futures:
f.cancel()
if self.stop_event.is_set():
for future in futures:
future.cancel()
finally:
if self.executor:
self.executor.shutdown(wait=False, cancel_futures=True)
self.executor = None
finally:
# 关闭线程池
if self._executor:
self._executor.shutdown(wait=False)
self._executor = None
state = "已停止" if self.stop_event.is_set() else "已完成"
self.status(stats.snapshot(state))
self.last_results = sorted(self.last_results, key=result_sort_key)
self._write_summary(title, stats, state)
self.done()
# 最终输出总结信息(开放端口列表)
if not self._stop_flag:
self._append_text("\n端口扫描完成。\n")
else:
self._append_text("\n端口扫描已停止。\n")
def _record_host_error(self, host: str, error: str, port_count: int, stats: ScanStats) -> None:
item = PortResult(
host=host,
resolved_ip="",
ip_version="",
port=0,
service="",
status="dns_error",
status_text=STATUS_TEXT["dns_error"],
error=error,
)
stats.record("dns_error", port_count)
self.last_results.append(item.to_dict())
self.result(item.to_dict())
self.output(f"{host} 解析失败: {error}\n", "warning")
self.status(stats.snapshot("运行中"))
if self._open_ports:
self._append_text(f"开放端口: {sorted(self._open_ports)}\n")
else:
self._append_text("未发现开放端口。\n")
def _record_result(self, item: PortResult, stats: ScanStats, options: ScanOptions) -> None:
stats.record(item.status)
row = item.to_dict()
self.last_results.append(row)
self.result(row)
# 启动后台管理线程
self._scan_thread = threading.Thread(target=manager, daemon=True)
self._scan_thread.start()
should_print = options.show_closed or item.status == "open" or item.status in {"dns_error", "error"}
if should_print:
tag = "success" if item.status == "open" else "warning" if item.status != "closed" else None
latency = f"{item.latency_ms:.0f} ms" if item.latency_ms else "-"
detail = item.banner or item.error
detail = f" {detail}" if detail else ""
self.output(
f"[{stats.scanned}/{stats.total}] {item.host}:{item.port:<5} "
f"{item.service:<10} {item.status_text:<6} {latency}{detail}\n",
tag,
)
# -------------------------
# 停止扫描
# -------------------------
def stop_scan(self):
"""
请求停止正在进行的扫描:设置停止标志并尝试关闭线程池。
- 已提交并正在运行的 socket.connect 调用无法被立即中断(但后续任务会被取消)
- stop_scan 尽快返回,实际终止需要等待正在运行的任务完成或超时
"""
if not (self._scan_thread and self._scan_thread.is_alive()):
messagebox.showinfo("提示", "当前没有正在运行的扫描任务。")
return
self.status(stats.snapshot("运行中"))
self._append_text("\n正在停止扫描,请稍候...\n")
self._stop_flag = True
def _scan_port(self, host_info: dict, port: int, options: ScanOptions) -> PortResult:
if self.stop_event.is_set():
return PortResult(
host=host_info["host"],
resolved_ip=host_info["resolved_ip"],
ip_version=host_info["ip_version"],
port=port,
service=service_name(port),
status="cancelled",
status_text=STATUS_TEXT["cancelled"],
)
# 尝试立即关闭线程池(不等待正在运行任务)
if self._executor:
try:
self._executor.shutdown(wait=False)
except Exception:
pass
started = time.perf_counter()
timeout = options.timeout_ms / 1000
try:
with socket.create_connection((host_info["resolved_ip"], port), timeout=timeout) as sock:
elapsed = (time.perf_counter() - started) * 1000
banner = self._read_banner(sock, host_info["host"], port, timeout) if options.banner_probe else ""
return PortResult(
host=host_info["host"],
resolved_ip=host_info["resolved_ip"],
ip_version=host_info["ip_version"],
port=port,
service=service_name(port),
status="open",
status_text=STATUS_TEXT["open"],
latency_ms=elapsed,
banner=banner,
)
except socket.timeout:
return self._closed_result(host_info, port, "timeout", "连接超时")
except OSError as exc:
status = classify_os_error(exc)
return self._closed_result(host_info, port, status, clean_error(exc))
def _closed_result(self, host_info: dict, port: int, status: str, error: str) -> PortResult:
return PortResult(
host=host_info["host"],
resolved_ip=host_info["resolved_ip"],
ip_version=host_info["ip_version"],
port=port,
service=service_name(port),
status=status,
status_text=STATUS_TEXT.get(status, STATUS_TEXT["error"]),
error=error,
)
def _read_banner(self, sock: socket.socket, host: str, port: int, timeout: float) -> str:
try:
sock.settimeout(min(max(timeout, 0.25), 1.0))
if port in HTTP_BANNER_PORTS:
request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n\r\n"
sock.sendall(request.encode("ascii", errors="ignore"))
data = sock.recv(256)
except Exception:
return ""
text = data.decode("utf-8", errors="replace")
text = re.sub(r"\s+", " ", text.replace("\x00", " ")).strip()
return text[:160]
def stop_scan(self) -> None:
if not self.is_scanning():
raise RuntimeError("当前没有正在运行的端口扫描")
self.stop_event.set()
if self.executor:
self.executor.shutdown(wait=False, cancel_futures=True)
self.output("\n正在停止端口扫描...\n", "warning")
def export_results(self, path: str) -> None:
if not self.last_results:
raise RuntimeError("还没有可导出的端口扫描结果")
fields = [
"host",
"resolved_ip",
"ip_version",
"port",
"service",
"status",
"status_text",
"latency_ms",
"banner",
"error",
"checked_at",
]
with open(path, "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for row in self.last_results:
writer.writerow({field_name: row.get(field_name, "") for field_name in fields})
def open_ports_summary(self) -> str:
open_items = [item for item in self.last_results if item.get("status") == "open"]
if not open_items:
return ""
grouped: dict[str, list[str]] = {}
for item in sorted(open_items, key=result_sort_key):
grouped.setdefault(item["host"], []).append(str(item["port"]))
return "\n".join(f"{host}: {', '.join(ports)}" for host, ports in grouped.items())
def describe_options(self, options: ScanOptions) -> str:
closed = "显示" if options.show_closed else "只显示开放端口"
banner = "开启" if options.banner_probe else "关闭"
return f"超时: {options.timeout_ms}ms 并发: {options.workers} 输出: {closed} Banner 探测: {banner}\n\n"
def normalize_options(self, options: Optional[dict]) -> ScanOptions:
options = options or {}
return ScanOptions(
timeout_ms=clamp_int(options.get("timeout_ms", 800), 100, 60000, "超时"),
workers=clamp_int(options.get("workers", 128), 1, 512, "并发数"),
show_closed=bool(options.get("show_closed", False)),
banner_probe=bool(options.get("banner_probe", False)),
)
def _write_summary(self, title: str, stats: ScanStats, state: str) -> None:
snapshot = stats.snapshot(state)
self.output(f"\n==== {title}统计 ====\n", "muted")
self.output(
f"状态: {state} 已扫: {snapshot['scanned']}/{snapshot['total']} "
f"开放: {snapshot['open']} 关闭: {snapshot['closed']} 超时: {snapshot['timeout']} "
f"不可达: {snapshot['unreachable']} 错误: {snapshot['error'] + snapshot['dns_error']} "
f"耗时: {snapshot['elapsed']:.1f}s\n",
"success" if snapshot["open"] else "warning",
)
summary = self.open_ports_summary()
if summary:
self.output("开放端口汇总:\n" + summary + "\n", "success")
# -------------------------
# 可选:返回当前扫描状态(供外部查询)
# -------------------------
def is_scanning(self) -> bool:
return bool(self._scan_thread and self._scan_thread.is_alive())
return bool(self.scan_thread and self.scan_thread.is_alive())
def normalize_ports(ports: Iterable[int]) -> list[int]:
unique = sorted({validate_port(port) for port in ports})
if not unique:
raise ValueError("请输入至少一个端口")
return unique
def parse_scan_hosts(text: str) -> list[str]:
raw = text.strip()
if not raw:
raise ValueError("请输入扫描目标")
targets = []
for part in re.split(r"[,;\s]+", raw):
item = part.strip()
if not item:
continue
if "/" in item:
network = ipaddress.ip_network(item, strict=False)
targets.extend(str(ip) for ip in network.hosts())
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}$", item):
prefix, tail = item.rsplit(".", 1)
start_text, end_text = tail.split("-", 1)
start = int(start_text)
end = int(end_text)
if start > end:
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(f"{prefix}.{value}")) for value in range(start, end + 1))
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}(?:\.\d{1,3}){3}$", item):
start_text, end_text = item.split("-", 1)
start_ip = ipaddress.ip_address(start_text)
end_ip = ipaddress.ip_address(end_text)
if start_ip.version != end_ip.version:
raise ValueError("IP 范围两端必须是同一 IP 版本")
if int(start_ip) > int(end_ip):
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(value)) for value in range(int(start_ip), int(end_ip) + 1))
else:
targets.append(validate_host(item))
unique = list(dict.fromkeys(targets))
if not unique:
raise ValueError("没有解析到有效目标")
return unique
def resolve_host(host: str) -> dict:
try:
parsed = ipaddress.ip_address(host)
return {"ok": True, "host": host, "resolved_ip": str(parsed), "ip_version": f"IPv{parsed.version}", "error": ""}
except ValueError:
pass
try:
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
return {"ok": False, "host": host, "resolved_ip": "", "ip_version": "", "error": clean_error(exc)}
addresses = []
for family, _socktype, _proto, _canonname, sockaddr in infos:
if family not in (socket.AF_INET, socket.AF_INET6):
continue
ip = sockaddr[0]
if ip not in addresses:
addresses.append(ip)
if not addresses:
return {"ok": False, "host": host, "resolved_ip": "", "ip_version": "", "error": "没有可用的 TCP 地址"}
selected = addresses[0]
version = "IPv6" if ":" in selected else "IPv4"
return {"ok": True, "host": host, "resolved_ip": selected, "ip_version": version, "error": ""}
def service_name(port: int) -> str:
if port <= 0:
return ""
if port in COMMON_SERVICES:
return COMMON_SERVICES[port]
try:
return socket.getservbyport(port, "tcp").upper()
except OSError:
return ""
def classify_os_error(exc: OSError) -> str:
code = getattr(exc, "winerror", None) or getattr(exc, "errno", None)
text = str(exc).lower()
if code in {10061, 111, 61} or "refused" in text or "拒绝" in text:
return "closed"
if code in {10051, 10064, 10065, 101, 113} or "unreachable" in text or "不可达" in text:
return "unreachable"
if "timed out" in text or "超时" in text:
return "timeout"
return "error"
def clean_error(exc: BaseException) -> str:
if isinstance(exc, OSError):
return exc.strerror or str(exc)
return str(exc)
def result_sort_key(item: dict) -> tuple:
host_key = item.get("resolved_ip") or item.get("host") or ""
try:
host_key = f"{int(ipaddress.ip_address(host_key)):039d}"
except ValueError:
pass
return (host_key, int(item.get("port") or 0))
def clamp_int(value, min_value: int, max_value: int, label: str) -> int:
try:
number = int(str(value).strip())
except ValueError as exc:
raise ValueError(f"{label}必须是整数") from exc
if number < min_value or number > max_value:
raise ValueError(f"{label}必须在 {min_value}-{max_value} 之间")
return number
+43 -58
View File
@@ -1,84 +1,69 @@
import subprocess
import threading
import tkinter as tk
from tkinter import scrolledtext, messagebox
from typing import Callable, Optional
from core.Function.common import popen_hidden, validate_host
OutputCallback = Callable[[str, Optional[str]], None]
DoneCallback = Callable[[], None]
class TracertFun:
def __init__(self, result_box: scrolledtext.ScrolledText):
self.result_box = result_box
def __init__(self, output: OutputCallback, done: Optional[DoneCallback] = None):
self.output = output
self.done = done or (lambda: None)
self.process = None
self.stop_flag = False
self.stop_event = threading.Event()
self.worker = None
def _append_text(self, text: str):
"""线程安全地输出到文本框"""
self.result_box.after(0, lambda: (
self.result_box.insert(tk.END, text),
self.result_box.see(tk.END)
))
def start_tracert(self, target: str, max_hops: int = 20, timeout_ms: int = 800) -> None:
target = validate_host(target)
max_hops = max(1, min(int(max_hops), 64))
timeout_ms = max(100, min(int(timeout_ms), 10000))
def start_tracert(self, target: str):
"""开始追踪"""
if self.process:
messagebox.showwarning("警告", "⚠️ 正在运行,请先停止再启动。")
return
if self.is_running():
raise RuntimeError("路由追踪正在运行,请先停止当前任务")
if not target.strip():
messagebox.showwarning("提示", "请输入目标地址!")
return
self.stop_event.clear()
command = ["tracert", "-d", "-w", str(timeout_ms), "-h", str(max_hops), target]
self.output(f"开始路由追踪: {target},最大 {max_hops} 跳,超时 {timeout_ms}ms\n\n", "muted")
self.worker = threading.Thread(target=self._run, args=(command,), daemon=True)
self.worker.start()
cmd = f'tracert -d -w 500 -h 20 {target}'
self.stop_flag = False
self._append_text(f"\n=== 开始追踪 {target} ===\n\n")
thread = threading.Thread(target=self._run_tracert, args=(cmd,))
thread.daemon = True
thread.start()
def _run_tracert(self, cmd: str):
"""执行 tracert 命令"""
def _run(self, command) -> None:
try:
self.process = subprocess.Popen(
cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
creationflags=subprocess.CREATE_NO_WINDOW # 隐藏控制台窗口
)
self.process = popen_hidden(command)
if not self.process.stdout:
return
for line in self.process.stdout:
if self.stop_flag:
if self.stop_event.is_set():
break
self._append_text(line)
except Exception as e:
self._append_text(f"\n❌ 错误: {e}\n")
self.output(line, None)
except Exception as exc:
self.output(f"\n路由追踪失败: {exc}\n", "error")
finally:
# 安全关闭进程
if self.process:
try:
self.process.terminate()
except Exception:
pass
self.process = None
if self.stop_flag:
self._append_text("\n=== 已停止追踪 ===\n")
if self.stop_event.is_set():
self.output("\n路由追踪已停止\n", "warning")
else:
self._append_text("\n--- 追踪结束 ---\n")
self.output("\n路由追踪完成\n", "success")
self.done()
def stop_tracert(self):
"""停止追踪"""
def stop_tracert(self) -> None:
if not self.is_running():
raise RuntimeError("当前没有正在运行的路由追踪")
self.stop_event.set()
if self.process:
self.stop_flag = True
try:
self.process.terminate()
except Exception:
pass
self.process = None
self._append_text("\n=== 已手动停止追踪 ===\n")
else:
messagebox.showinfo("提示", "当前没有正在运行的追踪任务。")
self.output("\n正在停止路由追踪...\n", "warning")
def is_running(self) -> bool:
return bool(self.worker and self.worker.is_alive())
+15 -15
View File
@@ -2,25 +2,25 @@ import logging
import os
import sys
def get_base_dir():
"""获取程序真实所在目录,兼容开发与打包"""
if getattr(sys, 'frozen', False): # 打包后的 exe
return os.path.dirname(sys.executable)
else: # 普通 Python 运行
return os.path.dirname(os.path.abspath(sys.argv[0]))
def setup_logger():
base_dir = get_base_dir()
log_file = os.path.join(base_dir, 'app.log') # 直接放在 main 同级目录
def get_base_dir() -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(sys.argv[0]))
def setup_logger() -> logging.Logger:
log_file = os.path.join(get_base_dir(), "app.log")
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.FileHandler(log_file, encoding='utf-8'),
logging.StreamHandler()
]
logging.FileHandler(log_file, encoding="utf-8"),
logging.StreamHandler(),
],
)
logging.info("日志系统初始化完成,日志文件路径:%s", log_file)
return logging.getLogger(__name__)
logger = logging.getLogger(__name__)
logger.info("日志系统初始化完成: %s", log_file)
return logger
+47 -88
View File
@@ -1,112 +1,71 @@
import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from tkinter import ttk
import logging
logger = logging.getLogger(__name__)
class BasicUI:
"""旧版页面的兼容辅助类。
新界面主要使用 core.ui.components;保留这个类是为了兼容后续可能仍引用
add_input/add_combobox/add_button 的小工具页。
"""
def add_input(
self, parent, label_text,
row, col=0, inivar="",
label_width=8, entry_width=20, colspan=1, sticky='w'
):
"""
添加一个带文本标签和输入框的组合控件,并返回 (StringVar, Frame) 以便后续控制。
- parent: 父容器
- label: 标签文字
- row, col: 放置在父容器的 grid 行列
- inivar: 初始值
- entry_width: 输入框宽度
- colspan: 该组控件在父容器上跨越的列数
- sticky: 对齐方式(默认左对齐)
"""
self,
parent,
label_text,
row,
col=0,
inivar="",
label_width=8,
entry_width=20,
colspan=1,
sticky="w",
):
group_frame = ttk.Frame(parent)
group_frame.grid(row=row, column=col, columnspan=colspan, sticky=sticky, padx=5, pady=3)
# 标签
label = ttk.Label(group_frame, text=label_text + "", width=label_width, anchor='w').grid(row=0, column=0, sticky='w', padx=(0, 5))
label = ttk.Label(group_frame, text=f"{label_text}:", width=label_width, anchor="w")
label.grid(row=0, column=0, sticky="w", padx=(0, 5))
# 输入框
var = tk.StringVar(value=inivar)
entry = ttk.Entry(group_frame, textvariable=var, width=entry_width)
entry.grid(row=0, column=1, sticky='w')
entry.grid(row=0, column=1, sticky="w")
return {
"frame": group_frame,
"label": label,
"entry": entry,
"var": var
}
return {"frame": group_frame, "label": label, "entry": entry, "var": var}
def add_combobox(
self, parent, label_text, row, col=0,
listbox=[], inivar=0, width=17, colspan=1,
sticky='w', label_width=8, state="readonly"
self,
parent,
label_text,
row,
col=0,
listbox=None,
inivar=0,
width=17,
colspan=1,
sticky="w",
label_width=8,
state="readonly",
):
"""
创建一组 [标签 + 下拉框] 控件。
返回 dict,方便外部单独或统一控制。
- values: 下拉选项列表
- default: 初始值(可选)
- state: "readonly" 表示只能从列表选,"normal" 可手动输入
"""
values = listbox or []
frame = ttk.Frame(parent)
frame.grid(row=row, column=col, columnspan=colspan, sticky=sticky, padx=5, pady=3)
# 标签
label = ttk.Label(
frame,
text=label_text + "",
width=label_width,
anchor='w'
)
label.grid(row=0, column=0, sticky='w', padx=(0, 5))
label = ttk.Label(frame, text=f"{label_text}:", width=label_width, anchor="w")
label.grid(row=0, column=0, sticky="w", padx=(0, 5))
# 变量 + 下拉框
var = tk.StringVar()
combobox = ttk.Combobox(
frame,
textvariable=var,
values=listbox,
width=width,
state=state
)
combobox.grid(row=0, column=1, sticky='w')
if inivar>=0:
var.set(listbox[inivar])
combobox = ttk.Combobox(frame, textvariable=var, values=values, width=width, state=state)
combobox.grid(row=0, column=1, sticky="w")
if values and inivar >= 0:
var.set(values[inivar])
return {
"frame": frame,
"label": label,
"combobox": combobox,
"var": var
}
return {"frame": frame, "label": label, "combobox": combobox, "var": var}
def add_button(
self, parent, button_text,
row, col=0, command="",
width=5, colspan=1, sticky='w'
):
"""
添加一个按钮,并返回 (StringVar, Frame) 以便后续控制。
- parent: 父容器
- button_text: 按钮文本
- row, col: 放置在父容器的 grid 行列
- command: 调用的函数
- width: 按钮宽度
- colspan: 该组控件在父容器上跨越的列数
- sticky: 对齐方式(默认左对齐)
"""
group_frame = ttk.Frame(parent)
group_frame.grid(row=row, column=col, columnspan=colspan, sticky=sticky, padx=5, pady=3)
btn = ttk.Button(group_frame, text=button_text, command=command, width=width)
btn.grid(row=0, column=0, sticky='w')
return {
"frame": group_frame,
"btn": btn,
}
def add_button(self, parent, button_text, row, col=0, command=None, width=5, colspan=1, sticky="w"):
group_frame = ttk.Frame(parent)
group_frame.grid(row=row, column=col, columnspan=colspan, sticky=sticky, padx=5, pady=3)
btn = ttk.Button(group_frame, text=button_text, command=command, width=width)
btn.grid(row=0, column=0, sticky="w")
return {"frame": group_frame, "btn": btn}
+144
View File
@@ -0,0 +1,144 @@
import tkinter as tk
from tkinter import scrolledtext, ttk
from typing import Callable, Optional
from core.ui.theme import COLORS, FONT_MONO, FONT_SECTION, FONT_SMALL
class Console:
def __init__(self, parent, height: int = 16):
self.widget = scrolledtext.ScrolledText(
parent,
height=height,
wrap="word",
bg=COLORS["console_bg"],
fg=COLORS["console_fg"],
insertbackground=COLORS["console_fg"],
selectbackground="#2b4a6f",
relief="flat",
borderwidth=0,
font=FONT_MONO,
padx=14,
pady=12,
)
self.widget.tag_config("muted", foreground=COLORS["console_muted"])
self.widget.tag_config("success", foreground="#86efac")
self.widget.tag_config("warning", foreground="#fde68a")
self.widget.tag_config("error", foreground="#fca5a5")
def grid(self, **kwargs):
self.widget.grid(**kwargs)
def pack(self, **kwargs):
self.widget.pack(**kwargs)
def clear(self) -> None:
self.widget.delete("1.0", tk.END)
def write(self, text: str, tag: Optional[str] = None) -> None:
def append():
self.widget.insert(tk.END, text, tag)
self.widget.see(tk.END)
self.widget.after(0, append)
class Page(ttk.Frame):
def __init__(self, parent, title: str, subtitle: str):
super().__init__(parent, style="Panel.TFrame")
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
header = ttk.Frame(self, style="Panel.TFrame")
header.grid(row=0, column=0, sticky="ew", padx=28, pady=(24, 8))
header.columnconfigure(0, weight=1)
ttk.Label(header, text=title, style="Title.TLabel").grid(row=0, column=0, sticky="w")
ttk.Label(header, text=subtitle, style="Muted.TLabel").grid(row=1, column=0, sticky="w", pady=(4, 0))
body_shell = ttk.Frame(self, style="Panel.TFrame")
body_shell.grid(row=1, column=0, sticky="nsew", padx=28, pady=(4, 20))
body_shell.columnconfigure(0, weight=1)
body_shell.rowconfigure(0, weight=1)
self._canvas = tk.Canvas(body_shell, bg=COLORS["panel"], highlightthickness=0, bd=0)
self._canvas.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(body_shell, orient="vertical", command=self._canvas.yview)
scrollbar.grid(row=0, column=1, sticky="ns", padx=(8, 0))
self._canvas.configure(yscrollcommand=scrollbar.set)
self.body = ttk.Frame(self._canvas, style="Panel.TFrame")
self.body.columnconfigure(0, weight=1)
self._body_window = self._canvas.create_window((0, 0), window=self.body, anchor="nw")
self.body.bind("<Configure>", self._update_scroll_region)
self._canvas.bind("<Configure>", self._resize_body)
self._bind_mousewheel(self._canvas)
def section(self, title: str, row: int, columns: int = 4):
frame = tk.Frame(self.body, bg=COLORS["panel"], highlightbackground=COLORS["border"], highlightthickness=1)
frame.grid(row=row, column=0, sticky="ew", pady=(0, 14))
for col in range(columns):
frame.columnconfigure(col, weight=1)
ttk.Label(frame, text=title, style="Section.TLabel").grid(
row=0, column=0, columnspan=columns, sticky="w", padx=18, pady=(14, 8)
)
return frame
def _update_scroll_region(self, _event=None) -> None:
self._canvas.configure(scrollregion=self._canvas.bbox("all"))
def _resize_body(self, event) -> None:
self._canvas.itemconfigure(self._body_window, width=event.width)
def _bind_mousewheel(self, widget) -> None:
widget.bind("<Enter>", lambda _event: widget.bind_all("<MouseWheel>", self._on_mousewheel))
widget.bind("<Leave>", lambda _event: widget.unbind_all("<MouseWheel>"))
def _on_mousewheel(self, event) -> None:
if not self.winfo_ismapped():
return
self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
def field(parent, label: str, row: int, column: int, value: str = "", width: int = 24, colspan: int = 1):
frame = ttk.Frame(parent, style="Panel.TFrame")
frame.grid(row=row, column=column, columnspan=colspan, sticky="ew", padx=18, pady=(4, 14))
frame.columnconfigure(0, weight=1)
ttk.Label(frame, text=label, style="Muted.TLabel").grid(row=0, column=0, sticky="w", pady=(0, 5))
var = tk.StringVar(value=value)
entry = ttk.Entry(frame, textvariable=var, width=width)
entry.grid(row=1, column=0, sticky="ew")
return {"frame": frame, "var": var, "entry": entry}
def combo(parent, label: str, row: int, column: int, values=None, value: str = "", width: int = 24, colspan: int = 1):
values = values or []
frame = ttk.Frame(parent, style="Panel.TFrame")
frame.grid(row=row, column=column, columnspan=colspan, sticky="ew", padx=18, pady=(4, 14))
frame.columnconfigure(0, weight=1)
ttk.Label(frame, text=label, style="Muted.TLabel").grid(row=0, column=0, sticky="w", pady=(0, 5))
var = tk.StringVar(value=value)
control = ttk.Combobox(frame, textvariable=var, values=values, width=width, state="readonly")
control.grid(row=1, column=0, sticky="ew")
return {"frame": frame, "var": var, "combobox": control}
def action_bar(parent, row: int, columnspan: int = 4):
frame = ttk.Frame(parent, style="Panel.TFrame")
frame.grid(row=row, column=0, columnspan=columnspan, sticky="ew", padx=18, pady=(0, 16))
return frame
def button(parent, text: str, command: Callable, style: str = "Secondary.TButton"):
btn = ttk.Button(parent, text=text, command=command, style=style)
btn.pack(side="left", padx=(0, 10))
return btn
def set_entry_state(item, enabled: bool) -> None:
item["entry"].configure(state="normal" if enabled else "disabled")
+224 -134
View File
@@ -1,159 +1,249 @@
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
import threading
import re
from tkinter import messagebox
import logging
from core.ui.basic_ui import BasicUI
from core.Function.network_fun import NetworkManager
from core.ui.components import Console, Page, action_bar, button, combo, field, set_entry_state
logger = logging.getLogger(__name__)
class NetworkTab(ttk.Frame, BasicUI):
"""网卡配置 Tab(兼容最新 NetworkManager"""
class NetworkTab(Page):
def __init__(self, parent):
super().__init__(parent)
super().__init__(parent, "网卡配置", "查看本机网卡信息,切换 DHCP,或写入静态 IPv4 / DNS 配置。")
self.body.rowconfigure(4, weight=1)
self.adapters = []
self.profiles = {}
self.networkname_list = []
self.networkconfig = []
self.build_ui()
self.netmgr = NetworkManager(self.result_box)
selector = self.section("网卡选择", 0, columns=4)
self.iface = combo(selector, "选择网卡", 1, 0, [], "", 44, colspan=2)
self.iface["combobox"].bind("<<ComboboxSelected>>", lambda _event: self.load_selected_adapter())
self.status = field(selector, "连接状态", 1, 2, "", 14)
self.status["entry"].configure(state="disabled")
actions = action_bar(selector, 2, 4)
self.refresh_btn = button(actions, "刷新网卡", self.refresh_adapters, "Primary.TButton")
def build_ui(self):
self.create_iface_section()
self.create_config_section()
self.create_action_section()
self.create_output_section()
identity = self.section("网卡信息", 1, columns=4)
self.description = field(identity, "设备描述", 1, 0, "", 46, colspan=2)
self.mac = field(identity, "MAC 地址", 1, 2, "", 24)
for item in (self.description, self.mac):
item["entry"].configure(state="disabled")
def refresh_allnetwork(self):
'''刷新网卡列表'''
self.networkname_list = []
try:
self.networkconfigs = self.netmgr.get_network_info()
for config in self.networkconfigs:
self.networkname_list.append(config.get("name", "未知网卡"))
self.iface_cb['combobox']['values'] = self.networkname_list
if self.iface_cb['var'].get() == "" :
self.iface_cb['var'].set(self.networkname_list[0])
self.result_box.insert(tk.END, f"获取网卡信息完成,共:{len(self.networkname_list)}个启用网卡\n")
self.refresh_network_callback()
config = self.section("IPv4 配置", 2, columns=4)
self.dhcp = combo(config, "地址模式", 1, 0, ["DHCP 自动获取", "静态手动配置"], "DHCP 自动获取", 18)
self.dhcp["combobox"].bind("<<ComboboxSelected>>", lambda _event: self.update_entry_state())
self.ipv4 = field(config, "IPv4 地址", 1, 1, "", 18)
self.netmask = field(config, "子网掩码", 1, 2, "", 18)
self.gateway = field(config, "默认网关", 1, 3, "", 18)
self.dns1 = field(config, "首选 DNS", 2, 1, "", 18)
self.dns2 = field(config, "备用 DNS", 2, 2, "", 18)
config_actions = action_bar(config, 3, 4)
self.apply_btn = button(config_actions, "应用配置", self.apply_settings, "Primary.TButton")
self.reload_btn = button(config_actions, "重新读取", self.refresh_adapters, "Secondary.TButton")
logger.info(f"获取网卡信息完成,共:{len(self.networkname_list)}个启用网卡")
logger.info(f"所有获取网卡信息:{self.networkconfigs}")
except Exception as e:
messagebox.showerror("错误", f"获取网卡信息失败: {e}")
logger.error(f"获取网卡信息失败: {e}")
self.networkconfigs = []
profiles = self.section("配置模板", 3, columns=4)
self.profile_name = field(profiles, "模板名称", 1, 0, "", 20)
self.profile_select = combo(profiles, "选择模板", 1, 1, [], "", 24)
profile_actions = action_bar(profiles, 2, 4)
self.save_profile_btn = button(profile_actions, "保存当前为模板", self.save_profile, "Primary.TButton")
self.apply_profile_btn = button(profile_actions, "套用模板到表单", self.apply_profile_to_form, "Secondary.TButton")
self.delete_profile_btn = button(profile_actions, "删除模板", self.delete_profile, "Danger.TButton")
# ---------------- UI 构建 ----------------
def create_iface_section(self):
frame = ttk.LabelFrame(self, text="网卡选择", padding=8)
frame.pack(side='top', fill='x', padx=10, pady=6)
output = self.section("输出控制台", 4, columns=1)
output.rowconfigure(1, weight=1)
output.columnconfigure(0, weight=1)
self.console = Console(output, height=12)
self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
self.iface_cb = self.add_combobox(frame, "选择网卡", row=0, col=0, listbox=[], width=42, inivar=-1)
self.iface_cb['combobox'].bind("<<ComboboxSelected>>", lambda e: self.refresh_network_callback())
self.refresh_btn = self.add_button(frame, "刷新网卡列表", row=0, col=1, width=15, command=self.refresh_allnetwork)
self.description_entry = self.add_input(frame, "网卡名称", row=1, col=0, inivar="", entry_width=45)
self.description_entry['entry'].config(state='disabled')
self.mac_entry = self.add_input(frame, "MAC地址", row=1, col=1, inivar="", entry_width=35)
self.mac_entry['entry'].config(state='disabled')
self.netmgr = NetworkManager(self.write)
self.load_profiles()
self.after(250, self.refresh_adapters)
def create_config_section(self):
frame = ttk.LabelFrame(self, text="IP 配置(编辑后点击应用)", padding=8)
frame.pack(side='top', fill='x', padx=10, pady=6)
def write(self, text, tag=None):
self.console.write(text, tag)
self.ip_entry = self.add_input(frame, "IPv4 地址", row=0, col=0, inivar="", entry_width=20)
self.mask_entry = self.add_input(frame, "子网掩码", row=0, col=1, inivar="", entry_width=20)
self.gw_entry = self.add_input(frame, "默认网关", row=0, col=2, inivar="", entry_width=20)
#self.dns1_entry = self.add_input(frame, "主 DNS", row=1, col=0, inivar="", entry_width=20)
#self.dns2_entry = self.add_input(frame, "备选 DNS", row=1, col=1, inivar="", entry_width=20)
self.dhcp_cb = self.add_combobox(frame, "自动获取", row=2, col=0, listbox=["",""], width=5, inivar=-1)
self.dhcp_cb['combobox'].bind("<<ComboboxSelected>>", lambda e: self.dhcp_entry_state())
def refresh_adapters(self, clear=True):
self.refresh_btn.configure(state="disabled")
self.reload_btn.configure(state="disabled")
if clear:
self.console.clear()
self.write("正在读取本机网卡信息...\n", "muted")
def create_action_section(self):
frame = ttk.LabelFrame(self, text="修改操作", padding=8)
frame.pack(side='top', fill='x', padx=10, pady=6)
def worker():
try:
adapters = self.netmgr.get_network_info()
self.after(0, lambda: self.on_adapters_loaded(adapters))
except Exception as exc:
self.after(0, lambda: self.on_refresh_failed(exc))
self.apply_btn = self.add_button(frame, "应用修改", row=0, col=0, width=10, command=self.apply_btn_callback)
self.refresh_info_btn = self.add_button(frame, "刷新当前信息", row=0, col=2, width=15, command=self.refresh_allnetwork)
threading.Thread(target=worker, daemon=True).start()
def create_output_section(self):
frame = ttk.LabelFrame(self, text="输出信息", padding=6)
frame.pack(side='top', fill='both', expand=True, padx=10, pady=6)
self.result_box = scrolledtext.ScrolledText(frame, width=100, height=12)
self.result_box.pack(fill='both', expand=True, padx=4, pady=4)
# ---------------- 事件回调 ----------------
def refresh_network_callback(self):
'''刷新当前网卡信息'''
# 刷新所有所有网卡信息
#self.refresh_allnetwork()
# 获取选中的网卡信息
iface_name = self.iface_cb['var'].get()
config = self.get_network_config(iface_name)
# 不可修改配置
self.description_entry['var'].set(config.get("description", ""))
self.mac_entry['var'].set(config.get("mac", ""))
# 可修改配置
self.ip_entry['var'].set(config.get("ipv4", ""))
self.mask_entry['var'].set(config.get("netmask", ""))
self.gw_entry['var'].set(config.get("gateway", ""))
#self.dns1_entry['var'].set(config.get("dns1", ""))
#self.dns2_entry['var'].set(config.get("dns2", ""))
if config.get("dhcp_enabled", "") == "":
self.dhcp_cb['var'].set("")
elif config.get("dhcp_enabled", "") == "":
self.dhcp_cb['var'].set("")
def on_adapters_loaded(self, adapters):
self.adapters = adapters
names = [adapter["name"] for adapter in adapters]
self.iface["combobox"]["values"] = names
if names:
current = self.iface["var"].get()
self.iface["var"].set(current if current in names else names[0])
self.load_selected_adapter()
self.write(f"读取完成,共发现 {len(names)} 个网卡\n", "success")
else:
self.dhcp_cb['var'].set("")
self.dhcp_entry_state()
self.get_network_settings()
self.output_network_settings()
self.write("未发现可用网卡\n", "warning")
self.refresh_btn.configure(state="normal")
self.reload_btn.configure(state="normal")
def get_network_config(self, iface_name):
'''根据网卡名称获取对应的配置信息'''
for config in self.networkconfigs:
if config.get("name") == iface_name:
return config
def on_refresh_failed(self, exc):
self.write(f"读取网卡失败: {exc}\n", "error")
self.refresh_btn.configure(state="normal")
self.reload_btn.configure(state="normal")
messagebox.showerror("读取网卡失败", str(exc))
def load_selected_adapter(self):
adapter = self.current_adapter()
if not adapter:
return
self.status["entry"].configure(state="normal")
self.description["entry"].configure(state="normal")
self.mac["entry"].configure(state="normal")
self.status["var"].set(adapter.get("status", ""))
self.description["var"].set(adapter.get("description", ""))
self.mac["var"].set(adapter.get("mac", ""))
self.ipv4["var"].set(adapter.get("ipv4", ""))
self.netmask["var"].set(adapter.get("netmask", ""))
self.gateway["var"].set(adapter.get("gateway", ""))
self.dns1["var"].set(adapter.get("dns1", ""))
self.dns2["var"].set(adapter.get("dns2", ""))
self.dhcp["var"].set("DHCP 自动获取" if adapter.get("dhcp_enabled") else "静态手动配置")
self.status["entry"].configure(state="disabled")
self.description["entry"].configure(state="disabled")
self.mac["entry"].configure(state="disabled")
self.update_entry_state()
self.write_current_adapter(adapter)
def current_adapter(self):
name = self.iface["var"].get()
for adapter in self.adapters:
if adapter.get("name") == name:
return adapter
return None
def dhcp_entry_state(self):
'''根据 DHCP 选择框设置输入框状态'''
if self.dhcp_cb['var'].get() == "":
# 禁用手动输入
self.ip_entry['entry'].config(state='disabled')
self.mask_entry['entry'].config(state='disabled')
self.gw_entry['entry'].config(state='disabled')
else:
# 启用手动输入
self.ip_entry['entry'].config(state='normal')
self.mask_entry['entry'].config(state='normal')
self.gw_entry['entry'].config(state='normal')
def update_entry_state(self):
static = self.dhcp["var"].get() == "静态手动配置"
for item in (self.ipv4, self.netmask, self.gateway, self.dns1, self.dns2):
set_entry_state(item, static)
def get_network_settings(self):
'''获取当前输入的网卡配置信息'''
self.networkconfig = {
"name": re.sub(r"^(.*?(适配器))\s*", "", self.iface_cb['var'].get()).strip(),
"ipv4": self.ip_entry['var'].get(),
"netmask": self.mask_entry['var'].get(),
"gateway": self.gw_entry['var'].get(),
#"dns1": self.dns1_entry['var'].get(),
#"dns2": self.dns2_entry['var'].get(),
"dhcp_enabled": self.dhcp_cb['var'].get() == ""
def write_current_adapter(self, adapter):
self.write("\n当前网卡:\n", "muted")
rows = [
("名称", adapter.get("name", "")),
("描述", adapter.get("description", "")),
("状态", adapter.get("status", "")),
("速率", adapter.get("link_speed", "")),
("接口索引", adapter.get("interface_index", "")),
("IPv4", adapter.get("ipv4", "")),
("IPv6", ", ".join(adapter.get("ipv6", []))),
("掩码", adapter.get("netmask", "")),
("网关", adapter.get("gateway", "")),
("DNS", ", ".join(value for value in (adapter.get("dns1", ""), adapter.get("dns2", "")) if value)),
("DHCP", "" if adapter.get("dhcp_enabled") else ""),
("DHCP 服务器", adapter.get("dhcp_server", "")),
("租约获取", adapter.get("dhcp_lease_obtained", "")),
("租约过期", adapter.get("dhcp_lease_expires", "")),
]
for key, value in rows:
self.write(f" {key}: {value}\n", "muted")
def collect_settings(self):
return {
"name": self.iface["var"].get(),
"dhcp_enabled": self.dhcp["var"].get() == "DHCP 自动获取",
"ipv4": self.ipv4["var"].get(),
"netmask": self.netmask["var"].get(),
"gateway": self.gateway["var"].get(),
"dns1": self.dns1["var"].get(),
"dns2": self.dns2["var"].get(),
}
def output_network_settings(self):
'''输出当前网卡配置信息到日志框'''
self.result_box.insert(tk.END, f"当前网卡配置:\n")
for key, value in self.networkconfig.items():
self.result_box.insert(tk.END, f" {key}: {value}\n")
self.result_box.see(tk.END)
def apply_settings(self):
settings = self.collect_settings()
if not settings["name"]:
messagebox.showwarning("无法应用配置", "请先选择网卡")
return
def apply_btn_callback(self):
'''应用当前网卡配置信息'''
self.get_network_settings()
self.netmgr.set_network_info(self.networkconfig)
#self.refresh_allnetwork()
self.apply_btn.configure(state="disabled")
self.write("\n开始应用配置。修改网卡通常需要管理员权限。\n", "warning")
def worker():
try:
self.netmgr.set_network_info(settings)
self.after(0, self.on_apply_success)
except Exception as exc:
self.after(0, lambda: self.on_apply_failed(exc))
threading.Thread(target=worker, daemon=True).start()
def on_apply_success(self):
self.apply_btn.configure(state="normal")
self.write("配置应用完成,正在刷新网卡信息...\n", "success")
self.refresh_adapters(clear=False)
def on_apply_failed(self, exc):
self.apply_btn.configure(state="normal")
self.write(f"配置应用失败: {exc}\n", "error")
messagebox.showerror("配置应用失败", f"{exc}\n\n请确认程序已用管理员权限运行。")
def load_profiles(self):
try:
self.profiles = self.netmgr.load_profiles()
except Exception as exc:
self.profiles = {}
self.write(f"读取模板失败: {exc}\n", "warning")
names = sorted(self.profiles.keys())
self.profile_select["combobox"]["values"] = names
if names and not self.profile_select["var"].get():
self.profile_select["var"].set(names[0])
def save_profile(self):
try:
name = self.profile_name["var"].get().strip()
if not name:
adapter = self.current_adapter()
name = adapter.get("name", "未命名模板") if adapter else "未命名模板"
self.netmgr.save_profile(name, self.collect_settings())
self.profile_name["var"].set(name)
self.profile_select["var"].set(name)
self.load_profiles()
self.write(f"已保存配置模板: {name}\n", "success")
except Exception as exc:
messagebox.showwarning("保存模板失败", str(exc))
def apply_profile_to_form(self):
name = self.profile_select["var"].get()
profile = self.profiles.get(name)
if not profile:
messagebox.showinfo("提示", "请选择要套用的模板")
return
self.dhcp["var"].set("DHCP 自动获取" if profile.get("dhcp_enabled") else "静态手动配置")
self.ipv4["var"].set(profile.get("ipv4", ""))
self.netmask["var"].set(profile.get("netmask", ""))
self.gateway["var"].set(profile.get("gateway", ""))
self.dns1["var"].set(profile.get("dns1", ""))
self.dns2["var"].set(profile.get("dns2", ""))
self.update_entry_state()
self.write(f"已套用模板到表单: {name}\n", "success")
def delete_profile(self):
name = self.profile_select["var"].get()
if not name:
messagebox.showinfo("提示", "请选择要删除的模板")
return
if not messagebox.askyesno("确认删除模板", f"确定删除配置模板“{name}”吗?"):
return
try:
self.netmgr.delete_profile(name)
self.profile_select["var"].set("")
self.load_profiles()
self.write(f"已删除配置模板: {name}\n", "success")
except Exception as exc:
messagebox.showwarning("删除模板失败", str(exc))
+213 -101
View File
@@ -1,122 +1,234 @@
import re
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from tkinter import filedialog, messagebox, ttk
from core.ui.basic_ui import BasicUI
from core.Function.network_fun import NetworkManager
from core.Function.ping_fun import PingFun
from core.ui.components import Console, Page, action_bar, button, combo, field
import logging
logger = logging.getLogger(__name__)
class PingTab(ttk.Frame, BasicUI):
DEFAULT_SOURCE = "默认路由"
IP_PATTERN = re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)")
class PingTab(Page):
def __init__(self, parent):
super().__init__(parent)
self.ping_ui()
self.ping_fun = PingFun(self.result_box)
super().__init__(parent, "Ping 探测", "单点 Ping、批量探活、参数化诊断与结果导出。")
self.body.rowconfigure(2, weight=1)
def ping_ui(self):
"""ping界面布局"""
self.create_assignIP_section()
self.create_batchIP_section()
self.create_outputping_section()
# --------------------------------------UI界面布局函数--------------------------------------
def create_assignIP_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="指定地址 Ping 目标地址")
frame.pack(side='top', fill='x', padx=10, pady=5)
status = self.section("实时状态", 0, columns=7)
self.state = field(status, "状态", 1, 0, "等待", 12)
self.sent = field(status, "发送", 1, 1, "0", 8)
self.received = field(status, "接收", 1, 2, "0", 8)
self.loss = field(status, "丢包率", 1, 3, "0.0%", 10)
self.avg = field(status, "平均延迟", 1, 4, "0.0 ms", 12)
self.jitter = field(status, "抖动", 1, 5, "0.0 ms", 12)
self.quality = field(status, "质量", 1, 6, "等待", 10)
for item in (self.state, self.sent, self.received, self.loss, self.avg, self.jitter, self.quality):
item["entry"].configure(state="disabled")
self.entry_assignIP_A = self.add_input(frame, "本地IP", row=0, col=0)
self.entry_assignIP_B = self.add_input(frame, "目标IP", row=0, col=1, inivar="127.0.0.1")
self.assignIP_startPing = self.add_button(frame, "开始", row=0, col=2, command=self.btn_assignIP_startPing)
self.assignIP_stopPing = self.add_button(frame, "停止", row=0, col=3, command=self.btn_assignIP_stopPing)
modes = self.section("探测模式", 1, columns=1)
self.tabs = ttk.Notebook(modes)
self.tabs.grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 18))
self.single_tab = ttk.Frame(self.tabs, style="Panel.TFrame")
self.batch_tab = ttk.Frame(self.tabs, style="Panel.TFrame")
self.tabs.add(self.single_tab, text="单点探测")
self.tabs.add(self.batch_tab, text="批量探活")
self.build_single_tab()
self.build_batch_tab()
def create_batchIP_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="指定地址 Ping 批量地址")
frame.pack(side='top', fill='x', padx=10, pady=5)
output = self.section("输出控制台", 2, columns=1)
output.rowconfigure(1, weight=1)
output.columnconfigure(0, weight=1)
self.console = Console(output, height=16)
self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
self.entry_batchIP_A = self.add_input(frame, "本地IP", row=0, col=0)
self.entry_batchIP_B = self.add_input(frame, "C类网段", row=0, col=1, inivar="192.168.1.", entry_width=15)
self.entry_batchIP_B_begin = self.add_input(frame, "起始地址", row=0, col=2, inivar="1", entry_width=5)
self.entry_batchIP_B_end = self.add_input(frame, "结束地址", row=0, col=3, inivar="255", entry_width=5)
self.batchIP_startPing = self.add_button(frame, "开始", row=0, col=4, command=self.btn_batchIP_startPing)
self.batchIP_stopPing = self.add_button(frame, "停止", row=0, col=5, command=self.btn_batchIP_stopPing)
self.ping_fun = PingFun(self.write, self.on_task_done, self.update_status)
self.source_loader = NetworkManager(lambda _text, _tag=None: None)
self.after(350, self.load_source_ips)
def create_outputping_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="PING 结果输出")
frame.pack(side='top', fill='x', padx=10, pady=5)
def build_single_tab(self):
for col in range(6):
self.single_tab.columnconfigure(col, weight=1)
self.result_box = scrolledtext.ScrolledText(frame, width=100, height=20)
self.result_box.pack(pady=10)
# --------------------------------------按钮回调函数--------------------------------------
def btn_assignIP_startPing(self):
if not self.entry_assignIP_B['var'].get():
messagebox.showwarning("输入错误", "请输入目标IP或域名!")
return
logger.info(f"开始由{self.entry_assignIP_A['var'].get()} Ping {self.entry_assignIP_B['var'].get()}")
self.ping_fun.strat_ping(self.entry_assignIP_B['var'].get(),local_ip=self.entry_assignIP_A['var'].get(),
callback=self.assignIP_ping_callback)
self.assignIP_startPing['btn'].config(state='disabled')
self.local_ip = combo(self.single_tab, "本地源 IP", 0, 0, [], "", 22)
self.target = field(self.single_tab, "目标 IP / 域名", 0, 1, "127.0.0.1", 28)
self.single_mode = combo(self.single_tab, "模式", 0, 2, ["持续", "指定次数"], "持续", 12)
self.count = field(self.single_tab, "次数", 0, 3, "4", 8)
self.interval = field(self.single_tab, "间隔 ms", 0, 4, "1000", 10)
self.timeout = field(self.single_tab, "超时 ms", 0, 5, "1200", 10)
def btn_assignIP_stopPing(self):
logger.info(f"停止由{self.entry_assignIP_A['var'].get()} Ping {self.entry_assignIP_B['var'].get()}")
self.size = field(self.single_tab, "包大小 bytes", 1, 0, "32", 12)
self.ttl = field(self.single_tab, "TTL0=默认)", 1, 1, "0", 12)
self.df_var = tk.BooleanVar(value=False)
df_frame = ttk.Frame(self.single_tab, style="Panel.TFrame")
df_frame.grid(row=1, column=2, sticky="ew", padx=18, pady=(22, 14))
ttk.Checkbutton(df_frame, text="禁止分片", variable=self.df_var).pack(anchor="w")
actions = action_bar(self.single_tab, 2, 6)
self.start_btn = button(actions, "开始 Ping", self.start_ping, "Primary.TButton")
self.stop_btn = button(actions, "停止", self.stop_ping, "Danger.TButton")
self.reload_sources_btn = button(actions, "刷新源 IP", self.load_source_ips, "Secondary.TButton")
def build_batch_tab(self):
for col in range(6):
self.batch_tab.columnconfigure(col, weight=1)
self.batch_local_ip = combo(self.batch_tab, "本地源 IP", 0, 0, [], "", 22)
self.targets = field(self.batch_tab, "目标范围 / 列表", 0, 1, "192.168.1.0/24", 42, colspan=2)
self.batch_timeout = field(self.batch_tab, "超时 ms", 0, 3, "1200", 10)
self.batch_size = field(self.batch_tab, "包大小 bytes", 0, 4, "32", 12)
self.workers = field(self.batch_tab, "并发数", 0, 5, "64", 10)
self.batch_ttl = field(self.batch_tab, "TTL0=默认)", 1, 0, "0", 12)
self.batch_df_var = tk.BooleanVar(value=False)
df_frame = ttk.Frame(self.batch_tab, style="Panel.TFrame")
df_frame.grid(row=1, column=1, sticky="ew", padx=18, pady=(22, 14))
ttk.Checkbutton(df_frame, text="禁止分片", variable=self.batch_df_var).pack(anchor="w")
actions = action_bar(self.batch_tab, 2, 6)
self.batch_start_btn = button(actions, "批量 Ping", self.start_batch_ping, "Primary.TButton")
self.batch_stop_btn = button(actions, "停止批量", self.stop_batch_ping, "Danger.TButton")
self.import_btn = button(actions, "导入目标", self.import_targets, "Secondary.TButton")
self.export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton")
self.reload_batch_sources_btn = button(actions, "刷新源 IP", self.load_source_ips, "Secondary.TButton")
def write(self, text, tag=None):
self.console.write(text, tag)
def clear(self):
self.console.clear()
self.update_status({"state": "等待", "sent": 0, "received": 0, "lost": 0, "loss_rate": 0, "avg_rtt": 0, "jitter": 0, "quality": "等待"})
def start_ping(self):
try:
self.clear()
self.ping_fun.start_ping(self.target["var"].get(), self.selected_source_ip(self.local_ip), self.single_options())
self._set_start_buttons("disabled")
except Exception as exc:
messagebox.showwarning("无法开始 Ping", str(exc))
def stop_ping(self):
self.ping_fun.stop_ping()
self.assignIP_startPing['btn'].config(state='normal')
def assignIP_ping_callback(self):
self.assignIP_startPing['btn'].config(state='normal')
def start_batch_ping(self):
try:
self.clear()
self.ping_fun.start_batch_ping(self.targets["var"].get(), self.selected_source_ip(self.batch_local_ip), self.batch_options())
self._set_start_buttons("disabled")
except Exception as exc:
messagebox.showwarning("无法开始批量 Ping", str(exc))
def btn_batchIP_startPing(self):
net_prefix = self.entry_batchIP_B['var'].get()
start = self.entry_batchIP_B_begin['var'].get()
end = self.entry_batchIP_B_end['var'].get()
local_ip = self.entry_batchIP_A['var'].get()
# ========= 输入参数检查 =========
if not net_prefix or start is None or end is None:
messagebox.showwarning("输入错误", "请输入完整的网段、起始地址和结束地址!")
def stop_batch_ping(self):
self.ping_fun.stop_batch_ping()
def single_options(self):
return {
"mode": self.single_mode["var"].get(),
"count": self.count["var"].get(),
"interval_ms": self.interval["var"].get(),
"timeout_ms": self.timeout["var"].get(),
"size": self.size["var"].get(),
"ttl": self.ttl["var"].get(),
"dont_fragment": self.df_var.get(),
}
def batch_options(self):
return {
"mode": "指定次数",
"count": 1,
"interval_ms": 1000,
"timeout_ms": self.batch_timeout["var"].get(),
"size": self.batch_size["var"].get(),
"ttl": self.batch_ttl["var"].get(),
"dont_fragment": self.batch_df_var.get(),
"workers": self.workers["var"].get(),
}
def update_status(self, stats):
def apply():
values = [
(self.state, stats.get("state", "等待")),
(self.sent, str(stats.get("sent", 0))),
(self.received, str(stats.get("received", 0))),
(self.loss, f"{stats.get('loss_rate', 0):.1f}%"),
(self.avg, f"{stats.get('avg_rtt', 0):.1f} ms"),
(self.jitter, f"{stats.get('jitter', 0):.1f} ms"),
(self.quality, stats.get("quality", "等待")),
]
for item, value in values:
item["entry"].configure(state="normal")
item["var"].set(value)
item["entry"].configure(state="disabled")
self.after(0, apply)
def load_source_ips(self):
def worker():
values = [DEFAULT_SOURCE]
try:
for adapter in self.source_loader.get_network_info():
ip = adapter.get("ipv4", "")
if ip:
name = adapter.get("name", "").strip()
values.append(f"{name} - {ip}" if name else ip)
except Exception as exc:
self.write(f"读取本地源 IP 失败: {exc}\n", "warning")
self.after(0, lambda: self.apply_source_ips(values))
threading.Thread(target=worker, daemon=True).start()
def apply_source_ips(self, values):
values = list(dict.fromkeys(values))
self.local_ip["combobox"]["values"] = values
self.batch_local_ip["combobox"]["values"] = values
if self.local_ip["var"].get() not in values:
self.local_ip["var"].set(values[0])
if self.batch_local_ip["var"].get() not in values:
self.batch_local_ip["var"].set(values[0])
def selected_source_ip(self, item):
value = item["var"].get().strip()
if not value or value == DEFAULT_SOURCE:
return ""
match = IP_PATTERN.search(value)
return match.group(0) if match else value
def import_targets(self):
path = filedialog.askopenfilename(
title="导入 Ping 目标",
filetypes=[("文本文件", "*.txt *.csv"), ("所有文件", "*.*")],
)
if not path:
return
try:
start = int(start)
end = int(end)
except ValueError:
messagebox.showwarning("输入错误", "起始地址和结束地址必须是整数!")
with open(path, "r", encoding="utf-8-sig") as file:
items = []
for line in file:
items.extend(part.strip() for part in line.replace("", ",").split(",") if part.strip())
self.targets["var"].set(",".join(items))
self.write(f"已导入 {len(items)} 个目标/表达式\n", "success")
except Exception as exc:
messagebox.showwarning("导入失败", str(exc))
def export_results(self):
path = filedialog.asksaveasfilename(
title="导出批量 Ping 结果",
defaultextension=".csv",
filetypes=[("CSV 文件", "*.csv")],
)
if not path:
return
if start < 1 or end > 255:
messagebox.showwarning("输入错误", "起始地址最小为1,结束地址最大为255!")
return
if start > end:
messagebox.showwarning("输入错误", "起始地址不能大于结束地址!")
return
if not net_prefix.endswith('.'):
messagebox.showwarning("输入错误", "网段必须以 '.' 结尾,例如:192.168.1.")
return
if local_ip == "":
logger.info(f"开始批量 Ping {net_prefix}{start} - {net_prefix}{end}")
else:
logger.info(f"开始由{local_ip} 批量 Ping {net_prefix}{start} - {net_prefix}{end}")
self.ping_fun.start_batch_ping(net_prefix, start, end, local_ip=local_ip, callback=self.batchIP_ping_callback)
self.batchIP_startPing['btn'].config(state='disabled')
def btn_batchIP_stopPing(self):
net_prefix = self.entry_batchIP_B['var'].get()
start = self.entry_batchIP_B_begin['var'].get()
end = self.entry_batchIP_B_end['var'].get()
local_ip = self.entry_batchIP_A['var'].get()
if local_ip == "":
logger.info(f"停止批量 Ping {net_prefix}{start} - {net_prefix}{end}")
else:
logger.info(f"停止由{local_ip} 批量 Ping {net_prefix}{start} - {net_prefix}{end}")
self.ping_fun.stop_batch_ping()
self.batchIP_startPing['btn'].config(state='normal')
def batchIP_ping_callback(self):
self.batchIP_startPing['btn'].config(state='normal')
try:
self.ping_fun.export_batch_results(path)
self.write(f"已导出结果: {path}\n", "success")
except Exception as exc:
messagebox.showwarning("导出失败", str(exc))
def on_task_done(self):
self.after(0, lambda: self._set_start_buttons("normal"))
def _set_start_buttons(self, state):
self.start_btn.configure(state=state)
self.batch_start_btn.configure(state=state)
+302 -109
View File
@@ -1,135 +1,328 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from tkinter import filedialog, messagebox, ttk
from core.ui.basic_ui import BasicUI
from core.Function.telnet_fun import PortScanner
from core.ui.components import Console, Page, action_bar, button, combo, field
import logging
logger = logging.getLogger(__name__)
class TelnetTab(ttk.Frame, BasicUI):
PORT_PRESETS = {
"常用端口": "21,22,23,25,53,80,110,139,143,443,445,1433,3306,3389,5432,6379,8080,8443,9200",
"Web 服务": "80,443,8000-8010,8080,8081,8443,8888,9000",
"数据库": "1433,1521,3306,5432,6379,9200,27017",
"远程管理": "22,23,3389,5900,5985,5986",
"邮件服务": "25,110,143,465,587,993,995",
"全端口": "1-65535",
}
class TelnetTab(Page):
def __init__(self, parent):
super().__init__(parent)
self.telnet_ui()
self.telnet_fun = PortScanner(self.result_box)
super().__init__(parent, "端口扫描", "单端口测试、端口扫描、批量主机巡检、服务识别与结果导出。")
self.body.rowconfigure(3, weight=1)
def telnet_ui(self):
"""端口扫描界面布局"""
self.create_assigntelnet_section()
self.create_listtelnet_section()
self.create_batchtelnet_section()
self.create_outputping_section()
# --------------------------------------UI界面布局函数--------------------------------------
def create_assigntelnet_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="指定端口连接测试")
frame.pack(side='top', fill='x', padx=10, pady=5)
status = self.section("实时状态", 0, columns=7)
self.state = field(status, "状态", 1, 0, "等待", 10)
self.total = field(status, "总数", 1, 1, "0", 8)
self.scanned = field(status, "已扫", 1, 2, "0", 8)
self.open_count = field(status, "开放", 1, 3, "0", 8)
self.closed_count = field(status, "关闭", 1, 4, "0", 8)
self.timeout_count = field(status, "超时", 1, 5, "0", 8)
self.progress = field(status, "进度 / 耗时", 1, 6, "0.0% / 0.0s", 16)
for item in (self.state, self.total, self.scanned, self.open_count, self.closed_count, self.timeout_count, self.progress):
item["entry"].configure(state="disabled")
#self.entry_assignIP_A = self.add_input(frame, "本地IP", row=0, col=0)
self.assignTelnet_IP = self.add_input(frame, "目标IP", row=0, col=0, inivar="202.89.233.100")
self.assignTelnet_port = self.add_input(frame, "目标端口", row=0, col=1, inivar="443", entry_width=10)
self.assignTelnet_test = self.add_button(frame, "测试", row=0, col=2, command=self.btn_assignTelnet_test)
modes = self.section("扫描模式", 1, columns=1)
self.tabs = ttk.Notebook(modes)
self.tabs.grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 18))
self.single_tab = ttk.Frame(self.tabs, style="Panel.TFrame")
self.scan_tab = ttk.Frame(self.tabs, style="Panel.TFrame")
self.batch_tab = ttk.Frame(self.tabs, style="Panel.TFrame")
self.tabs.add(self.single_tab, text="单端口测试")
self.tabs.add(self.scan_tab, text="端口扫描")
self.tabs.add(self.batch_tab, text="批量主机扫描")
self.build_single_tab()
self.build_scan_tab()
self.build_batch_tab()
def create_listtelnet_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="批量端口连接测试")
frame.pack(side='top', fill='x', padx=10, pady=5)
results = self.section("结果列表", 2, columns=1)
results.rowconfigure(1, weight=1)
results.columnconfigure(0, weight=1)
self.results_tree = ttk.Treeview(
results,
columns=("host", "resolved_ip", "port", "service", "status", "latency", "banner"),
show="headings",
height=8,
)
headings = {
"host": "目标",
"resolved_ip": "解析 IP",
"port": "端口",
"service": "服务",
"status": "状态",
"latency": "延迟",
"banner": "Banner / 错误",
}
widths = {
"host": 150,
"resolved_ip": 130,
"port": 70,
"service": 100,
"status": 80,
"latency": 80,
"banner": 320,
}
for column, title in headings.items():
self.results_tree.heading(column, text=title)
self.results_tree.column(column, width=widths[column], anchor="w")
self.results_tree.tag_configure("open", foreground="#15803d")
self.results_tree.tag_configure("timeout", foreground="#b7791f")
self.results_tree.tag_configure("unreachable", foreground="#b7791f")
self.results_tree.tag_configure("dns_error", foreground="#dc2626")
self.results_tree.tag_configure("error", foreground="#dc2626")
self.results_tree.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
result_scroll = ttk.Scrollbar(results, orient="vertical", command=self.results_tree.yview)
result_scroll.grid(row=1, column=1, sticky="ns", pady=(0, 18))
self.results_tree.configure(yscrollcommand=result_scroll.set)
self.listtelnet_IP = self.add_input(frame, "目标IP", row=0, col=0, inivar="202.89.233.100")
self.listtelnet_ports = self.add_input(frame, "端口列表", row=0, col=1, inivar="21,22,23,25,80,110,143,443,1433,3306,3389", entry_width=40)
self.listtelnet_start = self.add_button(frame, "开始", row=0, col=2, command=self.btn_listTelnet_start)
self.listtelnet_stop = self.add_button(frame, "停止", row=0, col=3, command=self.btn_listTelnet_stop)
output = self.section("输出控制台", 3, columns=1)
output.rowconfigure(1, weight=1)
output.columnconfigure(0, weight=1)
self.console = Console(output, height=12)
self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
def create_batchtelnet_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="批量端口连接测试")
frame.pack(side='top', fill='x', padx=10, pady=5)
self.scanner = PortScanner(self.write, self.on_task_done, self.update_status, self.add_result)
self.batchTelnet_IP = self.add_input(frame, "目标IP", row=0, col=0, inivar="202.89.233.100")
self.batchTelnet_port_begin = self.add_input(frame, "起始端口", row=0, col=1, inivar="100", entry_width=10)
self.batchTelnet_port_end = self.add_input(frame, "结束端口", row=0, col=2, inivar="1000", entry_width=10)
self.batchTelnet_start = self.add_button(frame, "开始", row=0, col=3, command=self.btn_batchTelnet_start)
self.batchTelnet_stop = self.add_button(frame, "停止", row=0, col=4, command=self.btn_batchTelnet_stop)
def build_single_tab(self):
for col in range(5):
self.single_tab.columnconfigure(col, weight=1)
def create_outputping_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="端口扫描结果输出")
frame.pack(side='top', fill='x', padx=10, pady=5)
self.single_host = field(self.single_tab, "目标 IP / 域名", 0, 0, "127.0.0.1", 26)
self.single_port = field(self.single_tab, "目标端口", 0, 1, "443", 12)
self.single_timeout = field(self.single_tab, "超时 ms", 0, 2, "1000", 10)
self.single_banner_var = tk.BooleanVar(value=False)
self._check(self.single_tab, "Banner 探测", self.single_banner_var, 0, 3)
actions = action_bar(self.single_tab, 1, 5)
self.single_btn = button(actions, "测试连接", self.test_single, "Primary.TButton")
self.single_export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton")
self.result_box = scrolledtext.ScrolledText(frame, width=100, height=20)
self.result_box.pack(pady=10)
# --------------------------------------按钮回调函数--------------------------------------
def btn_assignTelnet_test(self):
self.IP = self.assignTelnet_IP['var'].get()
self.port = self.assignTelnet_port['var'].get()
if not self.IP:
messagebox.showwarning("输入错误", "请输入目标IP或域名!")
return
if not self.port:
messagebox.showwarning("输入错误", "请输入目标端口!")
def build_scan_tab(self):
for col in range(6):
self.scan_tab.columnconfigure(col, weight=1)
self.scan_host = field(self.scan_tab, "目标 IP / 域名", 0, 0, "127.0.0.1", 26)
self.scan_ports = field(self.scan_tab, "端口列表 / 范围", 0, 1, PORT_PRESETS["常用端口"], 44, colspan=2)
self.scan_preset = combo(self.scan_tab, "预设", 0, 3, list(PORT_PRESETS), "常用端口", 16)
self.scan_timeout = field(self.scan_tab, "超时 ms", 0, 4, "800", 10)
self.scan_workers = field(self.scan_tab, "并发数", 0, 5, "128", 10)
self.scan_show_closed_var = tk.BooleanVar(value=False)
self.scan_banner_var = tk.BooleanVar(value=False)
self._check(self.scan_tab, "显示关闭端口", self.scan_show_closed_var, 1, 0)
self._check(self.scan_tab, "Banner 探测", self.scan_banner_var, 1, 1)
self.scan_preset["combobox"].bind("<<ComboboxSelected>>", lambda _event: self.apply_preset(self.scan_preset, self.scan_ports))
actions = action_bar(self.scan_tab, 2, 6)
self.scan_start_btn = button(actions, "开始扫描", self.scan_ports_action, "Primary.TButton")
self.scan_stop_btn = button(actions, "停止", self.stop_scan, "Danger.TButton")
self.scan_copy_btn = button(actions, "复制开放端口", self.copy_open_ports, "Secondary.TButton")
self.scan_export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton")
def build_batch_tab(self):
for col in range(6):
self.batch_tab.columnconfigure(col, weight=1)
self.batch_hosts = field(self.batch_tab, "目标列表 / CIDR / IP 段", 0, 0, "192.168.1.1-254", 38, colspan=2)
self.batch_ports = field(self.batch_tab, "端口列表 / 范围", 0, 2, "22,80,443,3389", 32, colspan=2)
self.batch_preset = combo(self.batch_tab, "预设", 0, 4, list(PORT_PRESETS), "远程管理", 16)
self.batch_timeout = field(self.batch_tab, "超时 ms", 0, 5, "800", 10)
self.batch_workers = field(self.batch_tab, "并发数", 1, 0, "128", 10)
self.batch_show_closed_var = tk.BooleanVar(value=False)
self.batch_banner_var = tk.BooleanVar(value=False)
self._check(self.batch_tab, "显示关闭端口", self.batch_show_closed_var, 1, 1)
self._check(self.batch_tab, "Banner 探测", self.batch_banner_var, 1, 2)
self.batch_preset["combobox"].bind("<<ComboboxSelected>>", lambda _event: self.apply_preset(self.batch_preset, self.batch_ports))
actions = action_bar(self.batch_tab, 2, 6)
self.batch_start_btn = button(actions, "批量扫描", self.batch_scan_action, "Primary.TButton")
self.batch_stop_btn = button(actions, "停止", self.stop_scan, "Danger.TButton")
self.import_hosts_btn = button(actions, "导入目标", self.import_hosts, "Secondary.TButton")
self.batch_copy_btn = button(actions, "复制开放端口", self.copy_open_ports, "Secondary.TButton")
self.batch_export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton")
def _check(self, parent, text, variable, row, column):
frame = ttk.Frame(parent, style="Panel.TFrame")
frame.grid(row=row, column=column, sticky="ew", padx=18, pady=(22, 14))
ttk.Checkbutton(frame, text=text, variable=variable).pack(anchor="w")
def write(self, text, tag=None):
self.console.write(text, tag)
def clear(self):
self.console.clear()
for item in self.results_tree.get_children():
self.results_tree.delete(item)
self.update_status(
{
"state": "等待",
"total": 0,
"scanned": 0,
"open": 0,
"closed": 0,
"timeout": 0,
"progress": 0,
"elapsed": 0,
}
)
def test_single(self):
try:
self.clear()
self.single_btn.configure(state="disabled")
self.scanner.test_connect(
self.single_host["var"].get(),
int(self.single_port["var"].get()),
options={
"timeout_ms": self.single_timeout["var"].get(),
"workers": 1,
"show_closed": True,
"banner_probe": self.single_banner_var.get(),
},
)
except Exception as exc:
self.single_btn.configure(state="normal")
messagebox.showwarning("无法测试端口", str(exc))
def scan_ports_action(self):
try:
self.clear()
self._set_scan_buttons("disabled")
self.scanner.start_scan(self.scan_host["var"].get(), self.scan_ports["var"].get(), self.scan_options())
except Exception as exc:
self._set_scan_buttons("normal")
messagebox.showwarning("无法扫描端口", str(exc))
def batch_scan_action(self):
try:
self.clear()
self._set_scan_buttons("disabled")
self.scanner.start_batch_scan(self.batch_hosts["var"].get(), self.batch_ports["var"].get(), self.batch_options())
except Exception as exc:
self._set_scan_buttons("normal")
messagebox.showwarning("无法批量扫描", str(exc))
def stop_scan(self):
try:
self.scanner.stop_scan()
except Exception as exc:
messagebox.showinfo("提示", str(exc))
def scan_options(self):
return {
"timeout_ms": self.scan_timeout["var"].get(),
"workers": self.scan_workers["var"].get(),
"show_closed": self.scan_show_closed_var.get(),
"banner_probe": self.scan_banner_var.get(),
}
def batch_options(self):
return {
"timeout_ms": self.batch_timeout["var"].get(),
"workers": self.batch_workers["var"].get(),
"show_closed": self.batch_show_closed_var.get(),
"banner_probe": self.batch_banner_var.get(),
}
def apply_preset(self, preset_item, ports_item):
ports_item["var"].set(PORT_PRESETS.get(preset_item["var"].get(), PORT_PRESETS["常用端口"]))
def import_hosts(self):
path = filedialog.askopenfilename(
title="导入扫描目标",
filetypes=[("文本文件", "*.txt *.csv"), ("所有文件", "*.*")],
)
if not path:
return
try:
port = int(self.port)
except ValueError:
messagebox.showwarning("输入错误", "端口必须是整数!")
return
if port < 1 or port > 65535:
messagebox.showwarning("输入错误", "起始端口最小为1,结束端口最大为65535!")
return
logger.info(f"测试{self.IP}{port} 端口连接情况")
self.telnet_fun.test_connect(self.IP, port)
with open(path, "r", encoding="utf-8-sig") as file:
items = []
for line in file:
items.extend(part.strip() for part in line.replace("", ",").split(",") if part.strip())
self.batch_hosts["var"].set(",".join(items))
self.write(f"已导入 {len(items)} 个目标/表达式\n", "success")
except Exception as exc:
messagebox.showwarning("导入失败", str(exc))
def btn_batchTelnet_start(self):
self.IP = self.batchTelnet_IP['var'].get()
self.port_begin = self.batchTelnet_port_begin['var'].get()
self.port_end = self.batchTelnet_port_end['var'].get()
if not self.IP:
messagebox.showwarning("输入错误", "请输入目标IP或域名!")
return
if not self.port_begin or not self.port_end:
messagebox.showwarning("输入错误", "请输入目标端口!")
def export_results(self):
path = filedialog.asksaveasfilename(
title="导出端口扫描结果",
defaultextension=".csv",
filetypes=[("CSV 文件", "*.csv")],
)
if not path:
return
try:
port_begin = int(self.port_begin)
port_end = int(self.port_end)
except ValueError:
messagebox.showwarning("输入错误", "端口必须是整数!")
self.scanner.export_results(path)
self.write(f"已导出结果: {path}\n", "success")
except Exception as exc:
messagebox.showwarning("导出失败", str(exc))
def copy_open_ports(self):
text = self.scanner.open_ports_summary()
if not text:
messagebox.showinfo("提示", "没有可复制的开放端口")
return
if port_begin < 1 or port_end > 65535:
messagebox.showwarning("输入错误", "起始端口最小为1,结束端口最大为65535!")
return
logger.info(f"开始测试{self.IP}{port_begin}{port_end} 端口连接情况")
self.telnet_fun.start_range_scan(self.IP, port_begin, port_end)
def btn_batchTelnet_stop(self):
logger.info(f"停止测试{self.IP} 的端口连接情况")
self.telnet_fun.stop_scan()
def btn_listTelnet_start(self):
self.IP = self.listtelnet_IP['var'].get()
self.ports = self.listtelnet_ports['var'].get()
if not self.ports:
messagebox.showwarning("输入错误", "请输入端口号或范围!")
return
# 支持格式:例如 "22,80,443,8080"
try:
ports = [int(p.strip()) for p in self.ports.split(',') if p.strip()]
except ValueError:
messagebox.showwarning("输入错误", "端口必须为整数,用逗号分隔")
return
logger.info(f"开始测试{self.IP}{ports} 端口连接情况")
self.telnet_fun.start_list_scan(self.IP, ports)
def btn_listTelnet_stop(self):
logger.info(f"停止测试{self.IP} 的端口连接情况")
self.telnet_fun.stop_scan()
self.clipboard_clear()
self.clipboard_append(text)
self.write("已复制开放端口汇总到剪贴板\n", "success")
def update_status(self, stats):
def apply():
values = [
(self.state, stats.get("state", "等待")),
(self.total, str(stats.get("total", 0))),
(self.scanned, str(stats.get("scanned", 0))),
(self.open_count, str(stats.get("open", 0))),
(self.closed_count, str(stats.get("closed", 0))),
(self.timeout_count, str(stats.get("timeout", 0))),
(self.progress, f"{stats.get('progress', 0):.1f}% / {stats.get('elapsed', 0):.1f}s"),
]
for item, value in values:
item["entry"].configure(state="normal")
item["var"].set(value)
item["entry"].configure(state="disabled")
self.after(0, apply)
def add_result(self, row):
def apply():
port = row.get("port") or "-"
latency = f"{row.get('latency_ms', 0):.1f} ms" if row.get("latency_ms") else "-"
detail = row.get("banner") or row.get("error") or ""
self.results_tree.insert(
"",
"end",
values=(
row.get("host", ""),
row.get("resolved_ip", ""),
port,
row.get("service", ""),
row.get("status_text", ""),
latency,
detail,
),
tags=(row.get("status", ""),),
)
self.after(0, apply)
def _set_scan_buttons(self, state):
self.scan_start_btn.configure(state=state)
self.batch_start_btn.configure(state=state)
def on_task_done(self):
self.after(
0,
lambda: (
self.single_btn.configure(state="normal"),
self._set_scan_buttons("normal"),
),
)
+38 -44
View File
@@ -1,56 +1,50 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from tkinter import messagebox
from core.ui.basic_ui import BasicUI
from core.Function.tracert_fun import TracertFun
from core.ui.components import Console, Page, action_bar, button, field
import logging
logger = logging.getLogger(__name__)
class TracertTab(ttk.Frame, BasicUI):
class TracertTab(Page):
def __init__(self, parent):
super().__init__(parent)
self.tracert_ui()
self.tracert_fun = TracertFun(self.result_box)
def tracert_ui(self):
"""tracert界面布局"""
self.create_targetadd_section()
self.create_output_section()
# --------------------------------------UI界面布局函数--------------------------------------
def create_targetadd_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="追踪目标地址")
frame.pack(side='top', fill='x', padx=10, pady=5)
self.entry_tracert_add = self.add_input(frame, "目标地址", row=0, col=0, entry_width=40, inivar="202.89.233.100")
self.but_tracert_start = self.add_button(frame, "开始追踪", row=0, col=1, width=8, command=self.tracert_start_callback)
self.but_tracert_stop = self.add_button(frame, "停止追踪", row=0, col=2, width=8, command=self.tracert_stop_callback)
def create_output_section(self):
# 区域标签
frame = ttk.LabelFrame(self, text="结果输出")
frame.pack(side='top', fill='x', padx=10, pady=5)
self.result_box = scrolledtext.ScrolledText(frame, width=100, height=25)
self.result_box.pack(pady=10)
# --------------------------------------按钮回调函数--------------------------------------
def tracert_start_callback(self):
"""开始追踪按钮回调"""
target = self.entry_tracert_add['var'].get()
if not target:
messagebox.showwarning("输入错误", "请输入目标地址!")
return
self.tracert_fun.start_tracert(target)
def tracert_stop_callback(self):
"""停止追踪按钮回调"""
self.tracert_fun.stop_tracert()
super().__init__(parent, "路由追踪", "查看从本机到目标地址的网络跳点和响应时间。")
self.body.rowconfigure(1, weight=1)
target = self.section("追踪参数", 0, columns=4)
self.target = field(target, "目标 IP / 域名", 1, 0, "8.8.8.8", 28)
self.max_hops = field(target, "最大跳数", 1, 1, "20", 12)
self.timeout_ms = field(target, "单跳超时(ms", 1, 2, "800", 14)
actions = action_bar(target, 2, 4)
self.start_btn = button(actions, "↗ 开始追踪", self.start_trace, "Primary.TButton")
self.stop_btn = button(actions, "■ 停止", self.stop_trace, "Danger.TButton")
output = self.section("输出控制台", 1, columns=1)
output.rowconfigure(1, weight=1)
output.columnconfigure(0, weight=1)
self.console = Console(output, height=22)
self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
self.tracert_fun = TracertFun(self.write, self.on_task_done)
def write(self, text, tag=None):
self.console.write(text, tag)
def start_trace(self):
try:
self.console.clear()
self.tracert_fun.start_tracert(
self.target["var"].get(),
int(self.max_hops["var"].get()),
int(self.timeout_ms["var"].get()),
)
self.start_btn.configure(state="disabled")
except Exception as exc:
messagebox.showwarning("无法开始路由追踪", str(exc))
def stop_trace(self):
try:
self.tracert_fun.stop_tracert()
except Exception as exc:
messagebox.showinfo("提示", str(exc))
def on_task_done(self):
self.after(0, lambda: self.start_btn.configure(state="normal"))
+110
View File
@@ -0,0 +1,110 @@
import tkinter as tk
from tkinter import ttk
COLORS = {
"bg": "#eef3f8",
"panel": "#ffffff",
"panel_alt": "#f7f9fc",
"sidebar": "#102033",
"sidebar_hover": "#1a3552",
"sidebar_active": "#246bfe",
"text": "#172033",
"muted": "#607086",
"border": "#dbe4ef",
"primary": "#246bfe",
"primary_dark": "#1451c8",
"danger": "#df3b3b",
"success": "#1a9b6c",
"warning": "#b7791f",
"console_bg": "#0f1724",
"console_fg": "#dbeafe",
"console_muted": "#9fb4d0",
}
FONT_BODY = ("Microsoft YaHei UI", 10)
FONT_SMALL = ("Microsoft YaHei UI", 9)
FONT_TITLE = ("Microsoft YaHei UI", 17, "bold")
FONT_SECTION = ("Microsoft YaHei UI", 11, "bold")
FONT_MONO = ("Consolas", 10)
def apply_theme(root: tk.Tk) -> None:
root.configure(bg=COLORS["bg"])
style = ttk.Style(root)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure(".", font=FONT_BODY)
style.configure("TFrame", background=COLORS["bg"])
style.configure("Panel.TFrame", background=COLORS["panel"])
style.configure("Alt.TFrame", background=COLORS["panel_alt"])
style.configure("TLabel", background=COLORS["panel"], foreground=COLORS["text"])
style.configure("Muted.TLabel", background=COLORS["panel"], foreground=COLORS["muted"], font=FONT_SMALL)
style.configure("Title.TLabel", background=COLORS["panel"], foreground=COLORS["text"], font=FONT_TITLE)
style.configure("Section.TLabel", background=COLORS["panel"], foreground=COLORS["text"], font=FONT_SECTION)
style.configure(
"TEntry",
fieldbackground="#ffffff",
foreground=COLORS["text"],
bordercolor=COLORS["border"],
lightcolor=COLORS["border"],
darkcolor=COLORS["border"],
padding=7,
)
style.configure(
"TCombobox",
fieldbackground="#ffffff",
foreground=COLORS["text"],
bordercolor=COLORS["border"],
arrowcolor=COLORS["muted"],
padding=6,
)
style.configure(
"Primary.TButton",
background=COLORS["primary"],
foreground="#ffffff",
borderwidth=0,
focusthickness=0,
padding=(14, 8),
)
style.map("Primary.TButton", background=[("active", COLORS["primary_dark"]), ("disabled", "#9bb7f5")])
style.configure(
"Secondary.TButton",
background="#e7eef8",
foreground=COLORS["text"],
borderwidth=0,
padding=(14, 8),
)
style.map("Secondary.TButton", background=[("active", "#d6e2f3")])
style.configure(
"Danger.TButton",
background=COLORS["danger"],
foreground="#ffffff",
borderwidth=0,
padding=(14, 8),
)
style.map("Danger.TButton", background=[("active", "#bd2929")])
def build_app_icon(size: int = 64) -> tk.PhotoImage:
image = tk.PhotoImage(width=size, height=size)
image.put(COLORS["sidebar"], to=(0, 0, size, size))
image.put(COLORS["primary"], to=(8, 8, size - 8, size - 8))
image.put("#5eead4", to=(16, 20, 27, 31))
image.put("#ffffff", to=(37, 20, 48, 31))
image.put("#ffffff", to=(16, 38, 27, 49))
image.put("#5eead4", to=(37, 38, 48, 49))
image.put("#ffffff", to=(24, 25, 41, 29))
image.put("#ffffff", to=(24, 42, 41, 46))
image.put("#ffffff", to=(21, 28, 25, 42))
image.put("#ffffff", to=(39, 28, 43, 42))
return image
+115 -22
View File
@@ -1,31 +1,124 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from tkinter import ttk
from core.ui.basic_ui import BasicUI
from core.ui.tab_network import NetworkTab
from core.ui.tab_ping import PingTab
from core.ui.tab_telnet import TelnetTab
from core.ui.tab_network import NetworkTab
from core.ui.tab_tracert import TracertTab
from core.ui.theme import COLORS, apply_theme, build_app_icon
import logging
logger = logging.getLogger(__name__)
class MainUI(BasicUI):
def __init__(self, root):
class MainUI:
def __init__(self, root: tk.Tk, base_dir: str = ""):
self.root = root
self.root.title("Network tools") # 窗口标题
self.root.geometry("800x500") # 窗口大小
root.resizable(False, False) # 禁止水平和垂直调整大小
# 创建 Notebook 作为多标签页容器
self.tab_control = ttk.Notebook(root)
self.tab_control.pack(expand=1, fill="both")
# 实例化各个功能页
self.tabs = {
"网卡设置": NetworkTab(self.tab_control),
"Ping测试": PingTab(self.tab_control),
"端口扫描": TelnetTab(self.tab_control),
"路由追踪": TracertTab(self.tab_control),
self.base_dir = base_dir
self.root.title("NetPilot 网络调试工具")
self.root.geometry("1160x720")
apply_theme(root)
self.icon_image = build_app_icon()
self.root.iconphoto(True, self.icon_image)
self.root.columnconfigure(1, weight=1)
self.root.rowconfigure(0, weight=1)
self.sidebar = tk.Frame(root, width=232, bg=COLORS["sidebar"])
self.sidebar.grid(row=0, column=0, sticky="ns")
self.sidebar.grid_propagate(False)
self.content = ttk.Frame(root, style="Panel.TFrame")
self.content.grid(row=0, column=1, sticky="nsew")
self.content.rowconfigure(0, weight=1)
self.content.columnconfigure(0, weight=1)
self.pages = {}
self.nav_buttons = {}
self._build_sidebar()
self._build_pages()
self.show_page("network")
def _build_sidebar(self) -> None:
brand = tk.Frame(self.sidebar, bg=COLORS["sidebar"])
brand.pack(fill="x", padx=20, pady=(24, 26))
logo = tk.Label(
brand,
text="NP",
width=4,
height=2,
bg=COLORS["primary"],
fg="#ffffff",
font=("Microsoft YaHei UI", 14, "bold"),
)
logo.pack(side="left")
title = tk.Frame(brand, bg=COLORS["sidebar"])
title.pack(side="left", padx=12)
tk.Label(title, text="NetPilot", bg=COLORS["sidebar"], fg="#ffffff", font=("Microsoft YaHei UI", 16, "bold")).pack(anchor="w")
tk.Label(title, text="网络调试控制台", bg=COLORS["sidebar"], fg="#9fb4d0", font=("Microsoft YaHei UI", 9)).pack(anchor="w")
items = [
("network", "", "网卡配置", "IP / DNS / DHCP"),
("ping", "", "Ping 探测", "单点与批量探活"),
("ports", "", "端口扫描", "TCP 连通性检测"),
("trace", "", "路由追踪", "跳点路径分析"),
]
for key, icon, title, subtitle in items:
self.nav_buttons[key] = self._nav_button(key, icon, title, subtitle)
footer = tk.Label(
self.sidebar,
text="v2 重构版",
bg=COLORS["sidebar"],
fg="#7185a0",
font=("Microsoft YaHei UI", 9),
)
footer.pack(side="bottom", anchor="w", padx=22, pady=20)
def _nav_button(self, key: str, icon: str, title: str, subtitle: str) -> tk.Frame:
frame = tk.Frame(self.sidebar, bg=COLORS["sidebar"], cursor="hand2")
frame.pack(fill="x", padx=14, pady=4)
icon_label = tk.Label(frame, text=icon, width=3, bg=COLORS["sidebar"], fg="#c8d7ec", font=("Microsoft YaHei UI", 18))
icon_label.pack(side="left", padx=(8, 6), pady=10)
text_frame = tk.Frame(frame, bg=COLORS["sidebar"])
text_frame.pack(side="left", fill="x", expand=True)
title_label = tk.Label(text_frame, text=title, bg=COLORS["sidebar"], fg="#ffffff", font=("Microsoft YaHei UI", 10, "bold"))
title_label.pack(anchor="w")
subtitle_label = tk.Label(text_frame, text=subtitle, bg=COLORS["sidebar"], fg="#9fb4d0", font=("Microsoft YaHei UI", 8))
subtitle_label.pack(anchor="w", pady=(2, 0))
widgets = (frame, icon_label, text_frame, title_label, subtitle_label)
for widget in widgets:
widget.bind("<Button-1>", lambda _event, page=key: self.show_page(page))
widget.bind("<Enter>", lambda _event, widgets=widgets, page=key: self._paint_nav(widgets, page, hover=True))
widget.bind("<Leave>", lambda _event, widgets=widgets, page=key: self._paint_nav(widgets, page, hover=False))
frame._nav_widgets = widgets
return frame
def _build_pages(self) -> None:
self.pages = {
"network": NetworkTab(self.content),
"ping": PingTab(self.content),
"ports": TelnetTab(self.content),
"trace": TracertTab(self.content),
}
# 添加到 Notebook
for name, tab in self.tabs.items():
self.tab_control.add(tab, text=name)
for page in self.pages.values():
page.grid(row=0, column=0, sticky="nsew")
def show_page(self, key: str) -> None:
self.active_page = key
self.pages[key].tkraise()
for nav_key, frame in self.nav_buttons.items():
self._paint_nav(frame._nav_widgets, nav_key, hover=False)
def _paint_nav(self, widgets, key: str, hover: bool) -> None:
active = getattr(self, "active_page", None) == key
bg = COLORS["sidebar_active"] if active else COLORS["sidebar_hover"] if hover else COLORS["sidebar"]
fg = "#ffffff" if active or hover else "#c8d7ec"
muted = "#dce9ff" if active else "#9fb4d0"
for index, widget in enumerate(widgets):
widget.configure(bg=bg)
if isinstance(widget, tk.Label):
widget.configure(fg=fg if index in (1, 3) else muted)
+16 -12
View File
@@ -1,21 +1,25 @@
import sys
import os
import sys
import tkinter as tk
from core.logger_config import setup_logger
from core.ui.ui_main import MainUI
if __name__ == "__main__":
# 进行日志配置
def get_base_dir() -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def main() -> None:
setup_logger()
# 判断是否是打包后的环境
if getattr(sys, 'frozen', False):
# 打包后的路径(exe所在的目录)
base_dir = os.path.dirname(sys.executable)
else:
# 普通Python运行时
base_dir = os.path.dirname(os.path.abspath(__file__))
# 启动主界面
root = tk.Tk()
app = MainUI(root)
root.minsize(960, 600)
MainUI(root, base_dir=get_base_dir())
root.mainloop()
if __name__ == "__main__":
main()
+18 -1
View File
@@ -1 +1,18 @@
pyinstaller -F -w main.py -n NetworkTool
@echo off
setlocal
set "PYTHON_EXE=C:\Users\Administrator\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
set "PYINSTALLER_PATH=%CD%\.packaging\pyinstaller"
if exist "%PYTHON_EXE%" (
if not exist "%PYINSTALLER_PATH%\PyInstaller\__main__.py" (
"%PYTHON_EXE%" -m pip install --target "%PYINSTALLER_PATH%" PyInstaller
if errorlevel 1 exit /b 1
)
set "PYTHONPATH=%PYINSTALLER_PATH%"
"%PYTHON_EXE%" -m PyInstaller -F -w main.py -n NetworkTool
) else (
pyinstaller -F -w main.py -n NetworkTool
)
endlocal