OK
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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, "TTL(0=默认)", 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, "TTL(0=默认)", 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
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user