a2a1f36092
- 完整 DHCP 流程: DISCOVER/OFFER/REQUEST/ACK/NAK/DECLINE/RELEASE/INFORM - 租约管理: 搜索/筛选/详情栏/解除/批量清除/CSV导出 - 保留地址: MAC→IP 绑定、独立租期、在线状态、导入导出 - 静态绑定/PXE 支持/冲突检测/租约持久化/防火墙一键放行 - 协议自检 25 项 + GUI 冒烟 21 项全部通过
2091 lines
88 KiB
Python
2091 lines
88 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
便携 DHCP 服务器 (Portable DHCP Server) v1.0
|
||
============================================
|
||
|
||
单文件、零第三方依赖(仅 Python 标准库)、带图形界面的轻量 DHCP 服务器,
|
||
适合在 Windows 电脑上为测试设备、摄像头、开发板、PXE 网络启动等临时分配 IP。
|
||
|
||
功能:
|
||
* 完整 DHCP 流程: DISCOVER / OFFER / REQUEST / ACK / NAK / DECLINE / RELEASE / INFORM
|
||
* 可视化配置: 地址池 / 子网掩码 / 网关 / DNS / 域名 / 租约时长
|
||
* 静态 IP 绑定 (MAC -> IP)
|
||
* 分配前 Ping 冲突检测
|
||
* 租约持久化(重启服务后客户端尽量保持原 IP)
|
||
* 实时日志、客户端列表、CSV 导出、防火墙一键放行
|
||
* PXE 支持: TFTP 服务器(option 66) + 启动文件(option 67)
|
||
|
||
运行:
|
||
python dhcp_server_app.py 启动图形界面
|
||
python dhcp_server_app.py --selftest 运行内置自检(不需要管理员)
|
||
|
||
注意:
|
||
* Windows 防火墙可能拦截发往 UDP 67 的入站报文, 请使用界面上的
|
||
「防火墙放行」按钮(需要管理员权限)。
|
||
* 若启动失败提示端口被占用, 通常是系统开启了「Internet 连接共享 / 移动热点」,
|
||
其内置的 DHCP 分配器占用了 UDP 67。
|
||
"""
|
||
|
||
import csv
|
||
import ctypes
|
||
import ipaddress
|
||
import json
|
||
import os
|
||
import queue
|
||
import re
|
||
import socket
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, ttk
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DHCP 常量
|
||
# ---------------------------------------------------------------------------
|
||
|
||
MAGIC_COOKIE = b'\x63\x82\x53\x63' # 99.130.83.99
|
||
|
||
# 报文类型
|
||
MSG_DISCOVER = 1
|
||
MSG_OFFER = 2
|
||
MSG_REQUEST = 3
|
||
MSG_DECLINE = 4
|
||
MSG_ACK = 5
|
||
MSG_NAK = 6
|
||
MSG_RELEASE = 7
|
||
MSG_INFORM = 8
|
||
|
||
MSG_NAMES = {
|
||
1: 'DISCOVER', 2: 'OFFER', 3: 'REQUEST', 4: 'DECLINE',
|
||
5: 'ACK', 6: 'NAK', 7: 'RELEASE', 8: 'INFORM',
|
||
}
|
||
|
||
# 常用 DHCP 选项
|
||
OPT_SUBNET_MASK = 1
|
||
OPT_ROUTER = 3
|
||
OPT_DNS = 6
|
||
OPT_HOSTNAME = 12
|
||
OPT_DOMAIN_NAME = 15
|
||
OPT_REQUESTED_IP = 50
|
||
OPT_LEASE_TIME = 51
|
||
OPT_MSG_TYPE = 53
|
||
OPT_SERVER_ID = 54
|
||
OPT_PARAM_REQ_LIST = 55
|
||
OPT_RENEWAL_TIME = 58
|
||
OPT_REBINDING_TIME = 59
|
||
OPT_CLIENT_ID = 61
|
||
OPT_TFTP_SERVER = 66
|
||
OPT_BOOTFILE_NAME = 67
|
||
OPT_END = 255
|
||
|
||
DHCP_SERVER_PORT = 67
|
||
DHCP_CLIENT_PORT = 68
|
||
|
||
STATE_NAMES = {'offer': '预分配', 'active': '已分配', 'expired': '已过期'}
|
||
|
||
|
||
def mkopt(code, value):
|
||
"""构造一个 DHCP 选项 (code, length, value)。"""
|
||
if isinstance(value, str):
|
||
value = value.encode('utf-8', 'replace')
|
||
return bytes([code, len(value)]) + value
|
||
|
||
|
||
def ip_to_bytes(ip):
|
||
return socket.inet_aton(ip if ip else '0.0.0.0')
|
||
|
||
|
||
def bytes_to_ip(b):
|
||
return socket.inet_ntoa(b) if b else '0.0.0.0'
|
||
|
||
|
||
def norm_mac(text):
|
||
"""把各种 MAC 写法统一为 AA:BB:CC:DD:EE:FF;不合法返回 None。"""
|
||
if not text:
|
||
return None
|
||
t = text.strip().replace('-', ':').replace('.', ':').replace(' ', '')
|
||
if re.fullmatch(r'([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', t):
|
||
return t.upper()
|
||
t2 = t.replace(':', '')
|
||
if re.fullmatch(r'[0-9A-Fa-f]{12}', t2):
|
||
return ':'.join(t2[i:i + 2] for i in range(0, 12, 2)).upper()
|
||
return None
|
||
|
||
|
||
def mac_to_bytes(mac):
|
||
return bytes.fromhex(mac.replace(':', '').replace('-', ''))
|
||
|
||
|
||
def format_secs(secs):
|
||
"""把秒数格式化为 'x天x小时x分'。"""
|
||
secs = int(secs)
|
||
if secs < 0:
|
||
return '已过期'
|
||
d, r = divmod(secs, 86400)
|
||
h, r = divmod(r, 3600)
|
||
m, _ = divmod(r, 60)
|
||
if d:
|
||
return f'{d}天{h}小时'
|
||
if h:
|
||
return f'{h}小时{m}分'
|
||
return f'{m}分{secs % 60}秒'
|
||
|
||
|
||
def list_local_ipv4():
|
||
"""枚举本机所有 IPv4 地址。"""
|
||
ips = set()
|
||
try:
|
||
for ip in socket.gethostbyname_ex(socket.gethostname())[2]:
|
||
if ':' not in ip:
|
||
ips.add(ip)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
try:
|
||
s.connect(('8.8.8.8', 80))
|
||
ips.add(s.getsockname()[0])
|
||
finally:
|
||
s.close()
|
||
except OSError:
|
||
pass
|
||
ips.discard('0.0.0.0')
|
||
return sorted(ips)
|
||
|
||
|
||
def is_admin():
|
||
"""当前进程是否具有管理员权限。"""
|
||
try:
|
||
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
||
except Exception:
|
||
return True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DHCP 报文解析 / 构造
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DhcpRequest:
|
||
"""已解析的 DHCP 请求报文。"""
|
||
|
||
__slots__ = ('op', 'htype', 'hlen', 'hops', 'xid_raw', 'xid', 'flags_raw',
|
||
'flags', 'broadcast', 'ciaddr_raw', 'ciaddr', 'yiaddr',
|
||
'siaddr', 'giaddr_raw', 'giaddr', 'chaddr_raw', 'chaddr_mac',
|
||
'sname', 'bootfile', 'options', 'hostname', 'client_id_mac',
|
||
'requested_ip')
|
||
|
||
def __repr__(self):
|
||
return (f'<DhcpRequest {MSG_NAMES.get(self.options.get(OPT_MSG_TYPE, b"")[0], "?")}'
|
||
f' xid=0x{self.xid:08X} mac={self.chaddr_mac}>')
|
||
|
||
|
||
def parse_dhcp_packet(data):
|
||
"""解析 DHCP 报文; 非 DHCP 报文返回 None。"""
|
||
if len(data) < 240 or data[236:240] != MAGIC_COOKIE:
|
||
return None
|
||
req = DhcpRequest()
|
||
req.op = data[0]
|
||
req.htype = data[1] or 1
|
||
req.hlen = data[2] or 6
|
||
req.hops = data[3]
|
||
req.xid_raw = data[4:8]
|
||
req.xid = struct.unpack('!I', data[4:8])[0]
|
||
req.flags_raw = data[10:12]
|
||
req.flags = struct.unpack('!H', data[10:12])[0]
|
||
req.broadcast = bool(req.flags & 0x8000)
|
||
req.ciaddr_raw = data[12:16]
|
||
req.ciaddr = bytes_to_ip(data[12:16])
|
||
req.yiaddr = bytes_to_ip(data[16:20])
|
||
req.siaddr = bytes_to_ip(data[20:24])
|
||
req.giaddr_raw = data[24:28]
|
||
req.giaddr = bytes_to_ip(data[24:28])
|
||
req.chaddr_raw = data[28:44]
|
||
req.chaddr_mac = ':'.join(f'{b:02X}' for b in data[28:28 + req.hlen])
|
||
req.sname = data[44:108].split(b'\x00')[0]
|
||
req.bootfile = data[108:236].split(b'\x00')[0]
|
||
|
||
# 选项区
|
||
options = {}
|
||
i = 240
|
||
n = len(data)
|
||
while i < n:
|
||
code = data[i]
|
||
if code == 0: # pad
|
||
i += 1
|
||
continue
|
||
if code == OPT_END: # end
|
||
break
|
||
if i + 1 >= n:
|
||
break
|
||
length = data[i + 1]
|
||
options[code] = data[i + 2:i + 2 + length]
|
||
i += 2 + length
|
||
req.options = options
|
||
|
||
# 主机名 (option 12)
|
||
raw = options.get(OPT_HOSTNAME)
|
||
req.hostname = ''
|
||
if raw:
|
||
try:
|
||
req.hostname = raw.decode('utf-8')
|
||
except UnicodeDecodeError:
|
||
req.hostname = raw.decode('gbk', 'replace')
|
||
req.hostname = req.hostname.strip()[:40]
|
||
|
||
# 客户端标识 (option 61): type(1) + 标识, type=1 时即 MAC
|
||
req.client_id_mac = None
|
||
cid = options.get(OPT_CLIENT_ID)
|
||
if cid and len(cid) == 7 and cid[0] == 1:
|
||
req.client_id_mac = ':'.join(f'{b:02X}' for b in cid[1:])
|
||
|
||
raw = options.get(OPT_REQUESTED_IP)
|
||
req.requested_ip = bytes_to_ip(raw) if raw and len(raw) == 4 else None
|
||
return req
|
||
|
||
|
||
def parse_ip_option(value):
|
||
"""把 4 字节选项值转成 IP 字符串, 失败返回 None。"""
|
||
if value and len(value) == 4:
|
||
return socket.inet_ntoa(value)
|
||
return None
|
||
|
||
|
||
def build_reply(req, msg_type, offered_ip, cfg, lease_secs):
|
||
"""根据请求构造应答报文 (BOOTREPLY)。"""
|
||
pkt = bytearray(240)
|
||
pkt[0] = 2 # op = BOOTREPLY
|
||
pkt[1] = req.htype
|
||
pkt[2] = req.hlen
|
||
pkt[3] = 0
|
||
pkt[4:8] = req.xid_raw
|
||
pkt[10:12] = req.flags_raw
|
||
# RENEWING/REBINDING/INFORM 时回显 ciaddr
|
||
renewing = (req.ciaddr_raw != b'\x00\x00\x00\x00'
|
||
and OPT_SERVER_ID not in req.options)
|
||
if renewing or msg_type == MSG_INFORM:
|
||
pkt[12:16] = req.ciaddr_raw
|
||
pkt[16:20] = ip_to_bytes(offered_ip)
|
||
pkt[20:24] = ip_to_bytes(cfg['server_ip'])
|
||
pkt[24:28] = req.giaddr_raw
|
||
pkt[28:44] = req.chaddr_raw
|
||
|
||
bootfile = (cfg.get('bootfile') or '')[:128]
|
||
if bootfile:
|
||
pkt[108:108 + len(bootfile)] = bootfile.encode('utf-8', 'replace')
|
||
|
||
pkt[236:240] = MAGIC_COOKIE
|
||
|
||
opts = bytearray()
|
||
opts += mkopt(OPT_MSG_TYPE, bytes([msg_type]))
|
||
opts += mkopt(OPT_SERVER_ID, ip_to_bytes(cfg['server_ip']))
|
||
|
||
if msg_type == MSG_NAK:
|
||
opts += bytes([OPT_END])
|
||
pkt += opts
|
||
return bytes(pkt)
|
||
|
||
if msg_type != MSG_INFORM and lease_secs:
|
||
lease_secs = int(lease_secs)
|
||
opts += mkopt(OPT_LEASE_TIME, struct.pack('!I', lease_secs))
|
||
opts += mkopt(OPT_RENEWAL_TIME, struct.pack('!I', lease_secs // 2))
|
||
opts += mkopt(OPT_REBINDING_TIME, struct.pack('!I', lease_secs * 7 // 8))
|
||
if cfg.get('mask'):
|
||
opts += mkopt(OPT_SUBNET_MASK, ip_to_bytes(cfg['mask']))
|
||
if cfg.get('gateway'):
|
||
opts += mkopt(OPT_ROUTER, ip_to_bytes(cfg['gateway']))
|
||
if cfg.get('dns'):
|
||
for dns in cfg['dns']:
|
||
opts += mkopt(OPT_DNS, ip_to_bytes(dns))
|
||
if cfg.get('domain'):
|
||
opts += mkopt(OPT_DOMAIN_NAME, cfg['domain'])
|
||
if cfg.get('tftp'):
|
||
opts += mkopt(OPT_TFTP_SERVER, cfg['tftp'])
|
||
if cfg.get('bootfile'):
|
||
opts += mkopt(OPT_BOOTFILE_NAME, cfg['bootfile'])
|
||
|
||
opts += bytes([OPT_END])
|
||
pkt += opts
|
||
if len(pkt) < 300: # BOOTP 报文至少 300 字节
|
||
pkt += b'\x00' * (300 - len(pkt))
|
||
return bytes(pkt)
|
||
|
||
|
||
def make_client_packet(msg_type, mac, xid, *, ciaddr='0.0.0.0',
|
||
requested_ip=None, server_id=None, hostname=None,
|
||
flags=0):
|
||
"""构造客户端报文(用于内置自检)。mac 为 'AA:BB:...' 形式。"""
|
||
mac_bytes = mac_to_bytes(mac)
|
||
pkt = bytearray(240)
|
||
pkt[0] = 1
|
||
pkt[1] = 1
|
||
pkt[2] = 6
|
||
pkt[4:8] = struct.pack('!I', xid)
|
||
pkt[10:12] = struct.pack('!H', flags)
|
||
pkt[12:16] = ip_to_bytes(ciaddr)
|
||
pkt[28:34] = mac_bytes
|
||
pkt[236:240] = MAGIC_COOKIE
|
||
opts = bytearray()
|
||
opts += mkopt(OPT_MSG_TYPE, bytes([msg_type]))
|
||
opts += mkopt(OPT_CLIENT_ID, b'\x01' + mac_bytes)
|
||
if requested_ip:
|
||
opts += mkopt(OPT_REQUESTED_IP, ip_to_bytes(requested_ip))
|
||
if server_id:
|
||
opts += mkopt(OPT_SERVER_ID, ip_to_bytes(server_id))
|
||
if hostname:
|
||
opts += mkopt(OPT_HOSTNAME, hostname)
|
||
opts += mkopt(OPT_PARAM_REQ_LIST, bytes([1, 3, 6, 15, 51]))
|
||
opts += bytes([OPT_END])
|
||
pkt += opts
|
||
return bytes(pkt)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DHCP 服务器引擎
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DhcpServerEngine:
|
||
"""DHCP 服务引擎: 在独立线程中监听 UDP 端口并处理租约。"""
|
||
|
||
OFFER_HOLD_SECS = 20 # OFFER 未确认的保留时间
|
||
SWEEP_INTERVAL = 15 # 租约扫描周期
|
||
SAVE_INTERVAL = 60 # 租约落盘周期
|
||
|
||
def __init__(self, config, events, lease_path=None):
|
||
self.cfg = dict(config)
|
||
self.events = events # queue.Queue, 事件回调
|
||
self.lease_path = lease_path
|
||
self.port = int(self.cfg.get('port', DHCP_SERVER_PORT))
|
||
self.leases = {} # ip -> {mac, hostname, state, expires, source}
|
||
self.mac_map = {} # mac -> ip
|
||
self.conflicts = set() # 已知冲突/被占用 IP
|
||
self.static = {} # mac -> ip
|
||
self.static_notes = {} # mac -> 备注
|
||
self.static_lease_secs = {} # mac -> 自定义租约秒数(0=使用全局默认)
|
||
self._lock = threading.RLock()
|
||
self.set_static_bindings(self.cfg.get('static_bindings') or [])
|
||
self.sock = None
|
||
self.running = False
|
||
self.started_at = None
|
||
self.counters = {'discover': 0, 'offer': 0, 'request': 0,
|
||
'ack': 0, 'nak': 0, 'release': 0, 'decline': 0}
|
||
self._thread = None
|
||
self._last_sweep = 0.0
|
||
self._last_save = 0.0
|
||
self._dirty = False
|
||
|
||
# ---- 事件 ---------------------------------------------------------------
|
||
|
||
def _emit(self, *event):
|
||
try:
|
||
self.events.put(event)
|
||
except Exception:
|
||
pass
|
||
|
||
def _log(self, text, level='packet'):
|
||
self._emit('log', level, text)
|
||
|
||
# ---- 生命周期 ------------------------------------------------------------
|
||
|
||
def start(self):
|
||
if self.running:
|
||
return
|
||
self._load_leases()
|
||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
try:
|
||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
self.sock.bind(('', self.port))
|
||
except OSError:
|
||
self.sock.close()
|
||
self.sock = None
|
||
raise
|
||
self.sock.settimeout(0.5)
|
||
self.running = True
|
||
self.started_at = time.time()
|
||
self._thread = threading.Thread(target=self._loop, name='dhcp-engine', daemon=True)
|
||
self._thread.start()
|
||
self._log(f'DHCP 服务已启动: {self.cfg["server_ip"]} · UDP {self.port} · '
|
||
f'地址池 {self.cfg["pool_start"]} ~ {self.cfg["pool_end"]}', 'ok')
|
||
self._emit('update')
|
||
|
||
def stop(self, reason='手动停止'):
|
||
if not self.running:
|
||
return
|
||
self.running = False
|
||
if self._thread:
|
||
self._thread.join(timeout=5)
|
||
self._thread = None
|
||
if self.sock:
|
||
try:
|
||
self.sock.close()
|
||
except OSError:
|
||
pass
|
||
self.sock = None
|
||
self._save_leases()
|
||
self._log(f'DHCP 服务已停止({reason}),租约已保存。', 'warn')
|
||
self._emit('update')
|
||
|
||
def _loop(self):
|
||
while self.running:
|
||
try:
|
||
data, addr = self.sock.recvfrom(4096)
|
||
except socket.timeout:
|
||
self._periodic()
|
||
continue
|
||
except OSError:
|
||
break # socket 被关闭
|
||
try:
|
||
self._handle(data, addr)
|
||
except Exception as exc: # 单个报文出错不影响服务
|
||
self._log(f'处理报文异常: {exc!r}', 'err')
|
||
self._periodic()
|
||
self._save_leases()
|
||
|
||
def _periodic(self):
|
||
now = time.time()
|
||
if now - self._last_sweep >= self.SWEEP_INTERVAL:
|
||
self._last_sweep = now
|
||
if self._sweep_expired():
|
||
self._emit('update')
|
||
if now - self._last_save >= self.SAVE_INTERVAL and self._dirty:
|
||
self._last_save = now
|
||
self._save_leases()
|
||
|
||
# ---- 租约维护 ------------------------------------------------------------
|
||
|
||
def _sweep_expired(self):
|
||
"""把过期租约标记为 expired, 返回是否有变化。"""
|
||
now = time.time()
|
||
changed = False
|
||
with self._lock:
|
||
for ip, lease in self.leases.items():
|
||
if lease['state'] == 'active' and lease['expires'] <= now:
|
||
lease['state'] = 'expired'
|
||
changed = True
|
||
self._dirty = True
|
||
self._log(f'[租约过期] {lease["mac"]} 的 {ip} 租约到期', 'info')
|
||
return changed
|
||
|
||
def _set_lease(self, ip, mac, hostname, state, source):
|
||
with self._lock:
|
||
old = self.leases.get(ip)
|
||
if old and old['mac'] != mac:
|
||
self.mac_map.pop(old['mac'], None)
|
||
self.leases[ip] = {'mac': mac, 'hostname': hostname or '',
|
||
'state': state, 'expires': 0.0,
|
||
'lease_secs': 0, 'source': source,
|
||
'updated': time.time()}
|
||
self.mac_map[mac] = ip
|
||
self._dirty = True
|
||
|
||
def _remove_lease(self, ip, new_state=None):
|
||
with self._lock:
|
||
lease = self.leases.pop(ip, None)
|
||
if lease:
|
||
self.mac_map.pop(lease['mac'], None)
|
||
self._dirty = True
|
||
if new_state:
|
||
self._log(f'租约 {ip} ({lease["mac"]}) 状态 -> {STATE_NAMES.get(new_state, new_state)}', 'info')
|
||
return lease
|
||
|
||
def set_static_bindings(self, bindings):
|
||
"""bindings: [{'mac':..., 'ip':..., 'note':..., 'lease_hours':...}, ...]"""
|
||
table = {}
|
||
notes = {}
|
||
lease_secs = {}
|
||
for item in bindings or []:
|
||
mac = norm_mac(item.get('mac', ''))
|
||
ip = item.get('ip', '')
|
||
if mac and ip:
|
||
table[mac] = ip
|
||
notes[mac] = (item.get('note') or '').strip()
|
||
try:
|
||
hours = int(item.get('lease_hours') or 0)
|
||
if 1 <= hours <= 8760:
|
||
lease_secs[mac] = hours * 3600
|
||
except (TypeError, ValueError):
|
||
pass
|
||
with self._lock:
|
||
self.static = table
|
||
self.static_notes = notes
|
||
self.static_lease_secs = lease_secs
|
||
|
||
def _lease_secs_for(self, mac):
|
||
"""该 MAC 的租约时长(秒): 保留地址可单独自定义, 否则用全局默认。"""
|
||
secs = self.static_lease_secs.get(mac, 0)
|
||
return secs if secs else int(self.cfg['lease_hours']) * 3600
|
||
|
||
# ---- 地址分配 ------------------------------------------------------------
|
||
|
||
def _excluded_ips(self):
|
||
"""不可分配的地址: 服务器自身 / 网关 / 静态绑定 / 网络号与广播地址。"""
|
||
excl = {self.cfg['server_ip']}
|
||
if self.cfg.get('gateway'):
|
||
excl.add(self.cfg['gateway'])
|
||
excl.update(self.static.values())
|
||
try:
|
||
net = ipaddress.ip_network(f'{self.cfg["server_ip"]}/{self.cfg["mask"]}', strict=False)
|
||
excl.add(str(net.network_address))
|
||
excl.add(str(net.broadcast_address))
|
||
except ValueError:
|
||
pass
|
||
return excl
|
||
|
||
def _candidate_ips(self, mac, requested_ip=None, hostname=''):
|
||
"""按优先级返回候选 IP 列表(未做冲突检测)。"""
|
||
pool_lo = int(ipaddress.ip_address(self.cfg['pool_start']))
|
||
pool_hi = int(ipaddress.ip_address(self.cfg['pool_end']))
|
||
excl = self._excluded_ips()
|
||
now = time.time()
|
||
with self._lock:
|
||
# 1) 静态绑定
|
||
if mac in self.static:
|
||
return [self.static[mac]]
|
||
# 2) 粘性: 同一 MAC 历史租约
|
||
sticky = self.mac_map.get(mac)
|
||
if sticky:
|
||
lease = self.leases.get(sticky)
|
||
if lease and sticky not in excl and sticky not in self.conflicts:
|
||
if lease['state'] in ('active', 'offer', 'expired'):
|
||
return [sticky]
|
||
# 3) 客户端点名的地址(合法且空闲)
|
||
if requested_ip:
|
||
ip_obj = None
|
||
try:
|
||
ip_obj = ipaddress.ip_address(requested_ip)
|
||
except ValueError:
|
||
ip_obj = None
|
||
if ip_obj and pool_lo <= int(ip_obj) <= pool_hi \
|
||
and requested_ip not in excl:
|
||
lease = self.leases.get(requested_ip)
|
||
if lease is None or lease['mac'] == mac or lease['state'] == 'expired':
|
||
if requested_ip not in self.conflicts:
|
||
return [requested_ip]
|
||
# 4) 顺序扫描地址池
|
||
candidates = []
|
||
for n in range(pool_lo, pool_hi + 1):
|
||
ip = str(ipaddress.ip_address(n))
|
||
if ip in excl or ip in self.conflicts:
|
||
continue
|
||
lease = self.leases.get(ip)
|
||
if lease and lease['state'] in ('active', 'offer') and lease['mac'] != mac:
|
||
continue
|
||
if lease and lease['state'] == 'offer' and lease['mac'] == mac \
|
||
and lease['expires'] > now:
|
||
return [ip] # 自己的未决 OFFER
|
||
candidates.append(ip)
|
||
if len(candidates) >= 3:
|
||
break
|
||
return candidates
|
||
|
||
def _ip_in_use(self, ip):
|
||
"""Ping 检测地址是否已被其他设备占用。"""
|
||
if not self.cfg.get('check_conflict', True):
|
||
return False
|
||
try:
|
||
out = subprocess.run(['ping', '-n', '1', '-w', '500', ip],
|
||
capture_output=True, timeout=3)
|
||
text = (out.stdout or b'').decode('gbk', 'ignore')
|
||
return 'TTL' in text.upper()
|
||
except Exception:
|
||
return False
|
||
|
||
def _allocate(self, mac, hostname='', requested_ip=None):
|
||
"""为 MAC 选择一个 IP; 无可用地址返回 None。"""
|
||
candidates = self._candidate_ips(mac, requested_ip, hostname)
|
||
for ip in candidates:
|
||
if self._ip_in_use(ip):
|
||
if ip not in self.conflicts:
|
||
self.conflicts.add(ip)
|
||
self._log(f'[冲突检测] {ip} 已被其他设备占用(Ping 有响应),跳过分配', 'warn')
|
||
continue
|
||
with self._lock:
|
||
lease = self.leases.get(ip)
|
||
if lease and lease['mac'] != mac and lease['state'] in ('active', 'offer'):
|
||
continue # 刚刚被别人抢走
|
||
return ip
|
||
return None
|
||
|
||
# ---- 报文处理 ------------------------------------------------------------
|
||
|
||
def _handle(self, data, addr):
|
||
req = parse_dhcp_packet(data)
|
||
if req is None or req.op != 1:
|
||
return
|
||
raw_type = req.options.get(OPT_MSG_TYPE)
|
||
if not raw_type:
|
||
return # 纯 BOOTP, 忽略
|
||
msg_type = raw_type[0]
|
||
|
||
mac = req.client_id_mac or req.chaddr_mac
|
||
host = f'主机名 {req.hostname}' if req.hostname else '无主机名'
|
||
|
||
if msg_type == MSG_DISCOVER:
|
||
self._on_discover(req, addr, mac, host)
|
||
elif msg_type == MSG_REQUEST:
|
||
self._on_request(req, addr, mac, host)
|
||
elif msg_type == MSG_DECLINE:
|
||
self._on_decline(req, mac, host)
|
||
elif msg_type == MSG_RELEASE:
|
||
self._on_release(req, mac, host)
|
||
elif msg_type == MSG_INFORM:
|
||
self._on_inform(req, addr, mac, host)
|
||
|
||
def _reply(self, req, addr, msg_type, offered_ip, lease_secs):
|
||
data = build_reply(req, msg_type, offered_ip, self.cfg, lease_secs)
|
||
giaddr = req.giaddr
|
||
if giaddr != '0.0.0.0': # 经过中继
|
||
target = (giaddr, DHCP_SERVER_PORT)
|
||
elif addr[0] not in ('0.0.0.0', '255.255.255.255'):
|
||
target = (addr[0], addr[1]) # 对端已有 IP(测试/中继场景)
|
||
elif req.broadcast or req.ciaddr == '0.0.0.0':
|
||
target = ('255.255.255.255', DHCP_CLIENT_PORT)
|
||
else:
|
||
target = (req.ciaddr, DHCP_CLIENT_PORT)
|
||
try:
|
||
self.sock.sendto(data, target)
|
||
except OSError as exc:
|
||
self._log(f'发送应答失败({target[0]}:{target[1]}): {exc}', 'err')
|
||
|
||
def _on_discover(self, req, addr, mac, host):
|
||
self.counters['discover'] += 1
|
||
ip = self._allocate(mac, req.hostname, req.requested_ip)
|
||
if ip is None:
|
||
self._log(f'[DISCOVER] {mac} ({host}) 请求地址,但地址池已无可用 IP', 'warn')
|
||
return
|
||
self._set_lease(ip, mac, req.hostname, 'offer',
|
||
'static' if mac in self.static else 'dynamic')
|
||
with self._lock:
|
||
self.leases[ip]['expires'] = time.time() + self.OFFER_HOLD_SECS
|
||
self.counters['offer'] += 1
|
||
lease_secs = self._lease_secs_for(mac)
|
||
with self._lock:
|
||
self.leases[ip]['lease_secs'] = lease_secs
|
||
self._log(f'[DISCOVER] {mac} ({host}) -> 预分配 {ip},已回复 OFFER')
|
||
self._reply(req, addr, MSG_OFFER, ip, lease_secs)
|
||
self._emit('update')
|
||
|
||
def _on_request(self, req, addr, mac, host):
|
||
self.counters['request'] += 1
|
||
lease_secs = self._lease_secs_for(mac)
|
||
server_id = parse_ip_option(req.options.get(OPT_SERVER_ID))
|
||
requested = req.requested_ip or (req.ciaddr if req.ciaddr != '0.0.0.0' else None)
|
||
|
||
# 客户端选择了其他 DHCP 服务器 -> 撤销我们的预分配
|
||
if server_id and server_id != self.cfg['server_ip']:
|
||
with self._lock:
|
||
ours = self.mac_map.get(mac)
|
||
if ours and self.leases.get(ours, {}).get('state') == 'offer':
|
||
self._remove_lease(ours)
|
||
self._log(f'[REQUEST] {mac} 选择了其他服务器 {server_id},已撤销预分配 {ours}', 'info')
|
||
self._emit('update')
|
||
return
|
||
|
||
with self._lock:
|
||
lease = self.leases.get(requested) if requested else None
|
||
static_ip = self.static.get(mac)
|
||
|
||
granted = None
|
||
if static_ip:
|
||
granted = static_ip # 静态绑定优先
|
||
elif lease and lease['mac'] == mac and lease['state'] in ('offer', 'active'):
|
||
granted = requested # 正常确认/续租
|
||
elif requested:
|
||
# INIT-REBOOT: 服务重启后客户端直接请求旧地址
|
||
pool_lo = int(ipaddress.ip_address(self.cfg['pool_start']))
|
||
pool_hi = int(ipaddress.ip_address(self.cfg['pool_end']))
|
||
excl = self._excluded_ips()
|
||
try:
|
||
in_pool = pool_lo <= int(ipaddress.ip_address(requested)) <= pool_hi
|
||
except ValueError:
|
||
in_pool = False
|
||
if in_pool and requested not in excl and requested not in self.conflicts \
|
||
and not self._ip_in_use(requested):
|
||
granted = requested
|
||
|
||
if granted:
|
||
if static_ip and granted != requested and requested:
|
||
self._log(f'[REQUEST] {mac} 请求 {requested},按静态绑定改配 {static_ip}', 'warn')
|
||
self._set_lease(granted, mac, req.hostname, 'active',
|
||
'static' if static_ip == granted else 'dynamic')
|
||
with self._lock:
|
||
self.leases[granted]['expires'] = time.time() + lease_secs
|
||
self.leases[granted]['lease_secs'] = lease_secs
|
||
self.counters['ack'] += 1
|
||
self._log(f'[REQUEST] {mac} ({host}) 确认租约 {granted},'
|
||
f'有效期 {format_secs(lease_secs)},已回复 ACK', 'ok')
|
||
self._reply(req, addr, MSG_ACK, granted, lease_secs)
|
||
else:
|
||
self.counters['nak'] += 1
|
||
self._log(f'[REQUEST] {mac} 请求 {requested or "?"} 不可用,已回复 NAK', 'warn')
|
||
self._reply(req, addr, MSG_NAK, None, 0)
|
||
self._emit('update')
|
||
|
||
def _on_decline(self, req, mac, host):
|
||
self.counters['decline'] += 1
|
||
ip = req.requested_ip
|
||
if ip:
|
||
self.conflicts.add(ip)
|
||
self._remove_lease(ip)
|
||
self._log(f'[DECLINE] {mac} ({host}) 报告 {ip} 存在地址冲突,已加入冲突名单', 'warn')
|
||
self._emit('update')
|
||
|
||
def _on_release(self, req, mac, host):
|
||
self.counters['release'] += 1
|
||
ip = req.ciaddr
|
||
with self._lock:
|
||
lease = self.leases.get(ip)
|
||
if lease and lease['mac'] == mac:
|
||
self._remove_lease(ip)
|
||
self._log(f'[RELEASE] {mac} ({host}) 主动释放了 {ip}', 'info')
|
||
self._emit('update')
|
||
else:
|
||
ours = self.mac_map.get(mac)
|
||
if ours:
|
||
self._remove_lease(ours)
|
||
self._log(f'[RELEASE] {mac} 释放了 {ours}', 'info')
|
||
self._emit('update')
|
||
|
||
def _on_inform(self, req, addr, mac, host):
|
||
self._log(f'[INFORM] {mac} ({host}) 请求网络参数,已回复 ACK(仅参数)', 'info')
|
||
self._reply(req, addr, MSG_ACK, None, 0)
|
||
|
||
# ---- 对外查询 ------------------------------------------------------------
|
||
|
||
def get_rows(self):
|
||
"""客户端列表快照。"""
|
||
now = time.time()
|
||
rows = []
|
||
with self._lock:
|
||
for ip in sorted(self.leases, key=lambda x: int(ipaddress.ip_address(x))):
|
||
lease = self.leases[ip]
|
||
remain = lease['expires'] - now if lease['state'] == 'active' else 0
|
||
rows.append({
|
||
'ip': ip,
|
||
'mac': lease['mac'],
|
||
'hostname': lease['hostname'],
|
||
'state': STATE_NAMES.get(lease['state'], lease['state']),
|
||
'source': '静态绑定' if lease.get('source') == 'static' else '动态分配',
|
||
'expires': (datetime.fromtimestamp(lease['expires']).strftime('%Y-%m-%d %H:%M:%S')
|
||
if lease['state'] == 'active' else '—'),
|
||
'remain': format_secs(remain) if lease['state'] == 'active' else '—',
|
||
'note': self.static_notes.get(lease['mac'], ''),
|
||
'last_seen': datetime.fromtimestamp(lease['updated']).strftime('%m-%d %H:%M:%S'),
|
||
'_state': lease['state'],
|
||
})
|
||
return rows
|
||
|
||
def stats(self):
|
||
try:
|
||
pool_lo = int(ipaddress.ip_address(self.cfg['pool_start']))
|
||
pool_hi = int(ipaddress.ip_address(self.cfg['pool_end']))
|
||
except ValueError:
|
||
pool_lo = pool_hi = 0
|
||
pool_size = max(0, pool_hi - pool_lo + 1)
|
||
with self._lock:
|
||
active = sum(1 for l in self.leases.values() if l['state'] == 'active')
|
||
offered = sum(1 for l in self.leases.values() if l['state'] == 'offer')
|
||
expired = sum(1 for l in self.leases.values() if l['state'] == 'expired')
|
||
static_in_pool = sum(1 for ip in self.static.values()
|
||
if pool_lo <= int(ipaddress.ip_address(ip)) <= pool_hi)
|
||
conflicts = len(self.conflicts)
|
||
available = max(0, pool_size - active - offered - static_in_pool - conflicts)
|
||
return {'pool_size': pool_size, 'active': active, 'offered': offered,
|
||
'expired': expired, 'conflicts': conflicts, 'available': available,
|
||
'static': len(self.static)}
|
||
|
||
def remove_lease(self, ip):
|
||
"""管理员手动解除租约。"""
|
||
with self._lock:
|
||
lease = self.leases.get(ip)
|
||
if lease:
|
||
self._remove_lease(ip)
|
||
self._log(f'已手动解除 {ip} ({lease["mac"]}) 的租约', 'warn')
|
||
self._emit('update')
|
||
return True
|
||
return False
|
||
|
||
def lease_holder(self, ip):
|
||
"""查询某 IP 当前租约归属; 无租约返回 None。"""
|
||
with self._lock:
|
||
lease = self.leases.get(ip)
|
||
return (lease['mac'], lease['state']) if lease else None
|
||
|
||
def remove_expired(self):
|
||
"""清除所有已过期租约, 返回数量。"""
|
||
with self._lock:
|
||
to_del = [ip for ip, l in self.leases.items() if l['state'] == 'expired']
|
||
for ip in to_del:
|
||
self._remove_lease(ip)
|
||
if to_del:
|
||
self._log(f'已清除 {len(to_del)} 条过期租约', 'warn')
|
||
self._emit('update')
|
||
return len(to_del)
|
||
|
||
def clear_all_leases(self):
|
||
"""清空所有租约记录(保留地址与冲突名单不受影响), 返回数量。"""
|
||
with self._lock:
|
||
n = len(self.leases)
|
||
self.leases.clear()
|
||
self.mac_map.clear()
|
||
self._dirty = True
|
||
if n:
|
||
self._log(f'已清空全部 {n} 条租约记录', 'warn')
|
||
self._emit('update')
|
||
return n
|
||
|
||
def get_reserved_summary(self):
|
||
"""保留地址概览: 在线 / 离线 / 从未上线。"""
|
||
rows = []
|
||
with self._lock:
|
||
for mac, ip in self.static.items():
|
||
if self.leases.get(ip, {}).get('mac') == mac \
|
||
and self.leases[ip]['state'] == 'active':
|
||
state = '在线'
|
||
elif mac in self.mac_map:
|
||
state = '离线'
|
||
else:
|
||
state = '从未上线'
|
||
rows.append({'mac': mac, 'ip': ip,
|
||
'note': self.static_notes.get(mac, ''),
|
||
'lease_hours': self.static_lease_secs.get(mac, 0) // 3600,
|
||
'status': state})
|
||
return rows
|
||
|
||
def clear_conflicts(self):
|
||
with self._lock:
|
||
n = len(self.conflicts)
|
||
self.conflicts.clear()
|
||
if n:
|
||
self._log(f'已清空冲突名单({n} 条)', 'info')
|
||
self._emit('update')
|
||
|
||
# ---- 租约持久化 ------------------------------------------------------------
|
||
|
||
def _load_leases(self):
|
||
if not self.lease_path or not os.path.exists(self.lease_path):
|
||
return
|
||
try:
|
||
with open(self.lease_path, 'r', encoding='utf-8') as f:
|
||
items = json.load(f)
|
||
except Exception as exc:
|
||
self._log(f'读取租约文件失败: {exc}', 'warn')
|
||
return
|
||
now = time.time()
|
||
loaded = 0
|
||
for item in items:
|
||
ip, mac = item.get('ip'), norm_mac(item.get('mac', ''))
|
||
if not ip or not mac:
|
||
continue
|
||
if mac in self.static and self.static[mac] != ip:
|
||
continue
|
||
expires = float(item.get('expires', 0))
|
||
state = item.get('state', 'active')
|
||
if state == 'active' and expires <= now:
|
||
state = 'expired'
|
||
with self._lock:
|
||
self.leases[ip] = {'mac': mac, 'hostname': item.get('hostname', ''),
|
||
'state': state, 'expires': expires,
|
||
'source': 'static' if mac in self.static else 'dynamic',
|
||
'updated': now}
|
||
self.mac_map[mac] = ip
|
||
loaded += 1
|
||
if loaded:
|
||
self._log(f'已从磁盘恢复 {loaded} 条租约记录', 'info')
|
||
|
||
def _save_leases(self):
|
||
if not self.lease_path:
|
||
return
|
||
with self._lock:
|
||
items = [{'ip': ip, 'mac': l['mac'], 'hostname': l['hostname'],
|
||
'expires': l['expires'], 'state': l['state']}
|
||
for ip, l in self.leases.items() if l['state'] != 'offer']
|
||
try:
|
||
tmp = self.lease_path + '.tmp'
|
||
with open(tmp, 'w', encoding='utf-8') as f:
|
||
json.dump(items, f, ensure_ascii=False, indent=1)
|
||
os.replace(tmp, self.lease_path)
|
||
self._dirty = False
|
||
except Exception as exc:
|
||
self._log(f'保存租约失败: {exc}', 'warn')
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 图形界面
|
||
# ---------------------------------------------------------------------------
|
||
|
||
APP_TITLE = '便携 DHCP 服务器 v1.0'
|
||
FW_RULE_NAME = 'PortableDhcpServer-UDP67'
|
||
|
||
|
||
class DhcpGui:
|
||
|
||
def __init__(self):
|
||
self.root = tk.Tk()
|
||
self.root.title(APP_TITLE)
|
||
self.root.geometry('1060x720')
|
||
self.root.minsize(960, 640)
|
||
# exe(PyInstaller --onefile) 时 __file__ 指向临时解压目录,
|
||
# 配置/租约必须落在 exe 同级目录, 否则退出后丢失
|
||
if getattr(sys, 'frozen', False):
|
||
self.base = os.path.dirname(sys.executable)
|
||
else:
|
||
try:
|
||
self.base = os.path.dirname(os.path.abspath(__file__))
|
||
except NameError:
|
||
self.base = os.getcwd()
|
||
self.cfg_path = os.path.join(self.base, 'dhcp_config.json')
|
||
self.lease_path = os.path.join(self.base, 'dhcp_leases.json')
|
||
|
||
self.events = queue.Queue()
|
||
self.engine = None
|
||
self._config_widgets = []
|
||
|
||
self._build_ui()
|
||
self._load_config()
|
||
self._log('欢迎使用便携 DHCP 服务器。请先确认「服务网卡 IP」与地址池设置,再点击「启动服务」。', 'info')
|
||
self._log('提示: 首次使用建议点击「防火墙放行」(需管理员权限),否则客户端的请求可能被 Windows 拦截。', 'info')
|
||
|
||
self.root.protocol('WM_DELETE_WINDOW', self._on_close)
|
||
self.root.after(150, self._poll_events)
|
||
self.root.after(2000, self._auto_refresh)
|
||
|
||
# ---- 界面搭建 ------------------------------------------------------------
|
||
|
||
def _build_ui(self):
|
||
style = ttk.Style(self.root)
|
||
try:
|
||
style.theme_use('vista')
|
||
except tk.TclError:
|
||
pass
|
||
|
||
root = ttk.Frame(self.root, padding=8)
|
||
root.pack(fill='both', expand=True)
|
||
root.columnconfigure(0, weight=1)
|
||
root.rowconfigure(2, weight=1)
|
||
|
||
# === 配置区 ===
|
||
cfgbox = ttk.LabelFrame(root, text=' 服务配置 ', padding=(10, 6))
|
||
cfgbox.grid(row=0, column=0, sticky='ew')
|
||
for c in (1, 3, 5, 7):
|
||
cfgbox.columnconfigure(c, weight=1)
|
||
|
||
def add_row(r, defn):
|
||
col = 0
|
||
for label, key, widget, width in defn:
|
||
ttk.Label(cfgbox, text=label).grid(row=r, column=col, sticky='e', padx=(6, 3), pady=3)
|
||
widget.grid(row=r, column=col + 1, sticky='ew', padx=(0, 10))
|
||
if width:
|
||
widget.configure(width=width)
|
||
self._config_widgets.append(widget)
|
||
if key:
|
||
setattr(self, key, widget)
|
||
col += 2
|
||
|
||
# 第 1 行
|
||
self.e_server_ip = ttk.Combobox(cfgbox, values=list_local_ipv4())
|
||
add_row(0, [('服务网卡 IP', 'e_server_ip', self.e_server_ip, 18),
|
||
('子网掩码', 'e_mask', ttk.Entry(cfgbox), 16)])
|
||
ttk.Button(cfgbox, text='刷新本机IP', width=10,
|
||
command=self._refresh_local_ips).grid(row=0, column=6, padx=(0, 6))
|
||
|
||
# 第 2 行
|
||
add_row(1, [('地址池起始', 'e_pool_start', ttk.Entry(cfgbox), 18),
|
||
('地址池结束', 'e_pool_end', ttk.Entry(cfgbox), 16)])
|
||
|
||
# 第 3 行
|
||
add_row(2, [('默认网关', 'e_gateway', ttk.Entry(cfgbox), 18),
|
||
('DNS 服务器(逗号分隔)', 'e_dns', ttk.Entry(cfgbox), 30)])
|
||
|
||
# 第 4 行
|
||
add_row(3, [('域名(可选)', 'e_domain', ttk.Entry(cfgbox), 18),
|
||
('租约时长(小时)', 'e_lease', ttk.Spinbox(cfgbox, from_=1, to=8760), 16)])
|
||
|
||
# 第 5 行
|
||
add_row(4, [('TFTP 服务器(可选,PXE)', 'e_tftp', ttk.Entry(cfgbox), 18),
|
||
('启动文件名(可选,PXE)', 'e_bootfile', ttk.Entry(cfgbox), 30)])
|
||
|
||
# 第 6 行: 选项
|
||
optrow = ttk.Frame(cfgbox)
|
||
optrow.grid(row=5, column=0, columnspan=8, sticky='w', pady=(4, 0))
|
||
self.v_conflict = tk.BooleanVar(value=True)
|
||
self.v_persist = tk.BooleanVar(value=True)
|
||
self.v_autoscroll = tk.BooleanVar(value=True)
|
||
ttk.Checkbutton(optrow, text='分配前 Ping 检测地址冲突',
|
||
variable=self.v_conflict).pack(side='left', padx=(4, 14))
|
||
ttk.Checkbutton(optrow, text='租约保存到磁盘(重启服务保留分配)',
|
||
variable=self.v_persist).pack(side='left', padx=(0, 14))
|
||
ttk.Button(optrow, text='按网卡 IP 自动填充默认值', command=self._autofill).pack(side='left')
|
||
|
||
# === 控制区 ===
|
||
ctrl = ttk.Frame(root)
|
||
ctrl.grid(row=1, column=0, sticky='ew', pady=(8, 6))
|
||
self.status_canvas = tk.Canvas(ctrl, width=14, height=14, highlightthickness=0)
|
||
self.status_canvas.pack(side='left')
|
||
self._set_status(False)
|
||
self.status_label = ttk.Label(ctrl, text='服务未启动', width=22, anchor='w')
|
||
self.status_label.pack(side='left', padx=(6, 18))
|
||
self.btn_start = ttk.Button(ctrl, text='▶ 启动服务', command=self._start_server)
|
||
self.btn_start.pack(side='left', padx=3)
|
||
self.btn_stop = ttk.Button(ctrl, text='■ 停止服务', command=self._stop_server, state='disabled')
|
||
self.btn_stop.pack(side='left', padx=3)
|
||
ttk.Separator(ctrl, orient='vertical').pack(side='left', fill='y', padx=10)
|
||
ttk.Button(ctrl, text='保存配置', command=self._save_config).pack(side='left', padx=3)
|
||
self.btn_fw = ttk.Button(ctrl, text='防火墙放行 UDP 67', command=self._fw_add)
|
||
self.btn_fw.pack(side='left', padx=3)
|
||
ttk.Button(ctrl, text='移除放行', command=self._fw_del).pack(side='left', padx=3)
|
||
ttk.Separator(ctrl, orient='vertical').pack(side='left', fill='y', padx=10)
|
||
self.admin_label = ttk.Label(ctrl, text='')
|
||
self.admin_label.pack(side='left', padx=(0, 4))
|
||
self._refresh_admin_status()
|
||
|
||
# === 选项卡 ===
|
||
nb = ttk.Notebook(root)
|
||
nb.grid(row=2, column=0, sticky='nsew')
|
||
|
||
# -- 日志页 --
|
||
log_frame = ttk.Frame(nb, padding=4)
|
||
nb.add(log_frame, text=' 运行日志 ')
|
||
self.txt = tk.Text(log_frame, wrap='none', font=('Consolas', 10),
|
||
state='disabled', bg='#fafafa', relief='flat')
|
||
ys = ttk.Scrollbar(log_frame, orient='vertical', command=self.txt.yview)
|
||
xs = ttk.Scrollbar(log_frame, orient='horizontal', command=self.txt.xview)
|
||
self.txt.configure(yscrollcommand=ys.set, xscrollcommand=xs.set)
|
||
self.txt.grid(row=0, column=0, sticky='nsew')
|
||
ys.grid(row=0, column=1, sticky='ns')
|
||
xs.grid(row=1, column=0, sticky='ew')
|
||
log_frame.rowconfigure(0, weight=1)
|
||
log_frame.columnconfigure(0, weight=1)
|
||
for tag, color in (('info', '#333333'), ('packet', '#1a5fb4'),
|
||
('ok', '#26a269'), ('warn', '#b45800'), ('err', '#c01c28')):
|
||
self.txt.tag_configure(tag, foreground=color)
|
||
logbar = ttk.Frame(log_frame)
|
||
logbar.grid(row=2, column=0, columnspan=2, sticky='ew', pady=(4, 0))
|
||
ttk.Checkbutton(logbar, text='自动滚动', variable=self.v_autoscroll).pack(side='left')
|
||
ttk.Button(logbar, text='清空日志', command=self._clear_log).pack(side='left', padx=10)
|
||
ttk.Button(logbar, text='清空冲突名单', command=self._clear_conflicts_clicked).pack(side='left')
|
||
|
||
# -- 客户端页 --
|
||
cli_frame = ttk.Frame(nb, padding=4)
|
||
nb.add(cli_frame, text=' 客户端列表 ')
|
||
cols = ('ip', 'mac', 'hostname', 'state', 'source', 'expires', 'remain', 'note', 'last_seen')
|
||
heads = ('IP 地址', 'MAC 地址', '主机名', '状态', '来源', '租约到期', '剩余有效期', '备注', '最近活动')
|
||
widths = (120, 150, 130, 70, 80, 150, 90, 110, 110)
|
||
|
||
clitop = ttk.Frame(cli_frame)
|
||
clitop.grid(row=0, column=0, columnspan=2, sticky='ew', pady=(0, 4))
|
||
self.var_query = tk.StringVar()
|
||
self.var_query.trace_add('write', lambda *a: self._refresh_clients())
|
||
ttk.Label(clitop, text='搜索:').pack(side='left')
|
||
ttk.Entry(clitop, textvariable=self.var_query, width=22).pack(side='left', padx=(4, 12))
|
||
ttk.Label(clitop, text='状态:').pack(side='left')
|
||
self.var_state = tk.StringVar(value='全部')
|
||
st_combo = ttk.Combobox(clitop, textvariable=self.var_state, state='readonly', width=12,
|
||
values=('全部', '已分配', '预分配', '已过期'))
|
||
st_combo.pack(side='left', padx=(4, 12))
|
||
st_combo.bind('<<ComboboxSelected>>', lambda e: self._refresh_clients())
|
||
ttk.Button(clitop, text='刷新', command=self._refresh_clients).pack(side='left')
|
||
|
||
self.cli_tree = ttk.Treeview(cli_frame, columns=cols, show='headings', height=13)
|
||
for c, h, w in zip(cols, heads, widths):
|
||
self.cli_tree.heading(c, text=h)
|
||
self.cli_tree.column(c, width=w, anchor='w')
|
||
cys = ttk.Scrollbar(cli_frame, orient='vertical', command=self.cli_tree.yview)
|
||
self.cli_tree.configure(yscrollcommand=cys.set)
|
||
self.cli_tree.grid(row=1, column=0, sticky='nsew')
|
||
cys.grid(row=1, column=1, sticky='ns')
|
||
cli_frame.rowconfigure(1, weight=1)
|
||
cli_frame.columnconfigure(0, weight=1)
|
||
|
||
self.cli_detail = ttk.Label(cli_frame, text='未选择客户端', foreground='#555', anchor='w')
|
||
self.cli_detail.grid(row=2, column=0, columnspan=2, sticky='ew', pady=(4, 0))
|
||
|
||
clibar = ttk.Frame(cli_frame)
|
||
clibar.grid(row=3, column=0, columnspan=2, sticky='ew', pady=(4, 0))
|
||
ttk.Button(clibar, text='解除选中租约', command=self._remove_selected_lease).pack(side='left')
|
||
ttk.Button(clibar, text='设为保留地址…', command=self._reserve_selected).pack(side='left', padx=6)
|
||
ttk.Button(clibar, text='导出 CSV', command=self._export_csv).pack(side='left', padx=6)
|
||
ttk.Button(clibar, text='清空过期租约', command=self._clear_expired_clicked).pack(side='left', padx=6)
|
||
ttk.Button(clibar, text='清空全部租约', command=self._clear_all_clicked).pack(side='left', padx=6)
|
||
ttk.Label(clibar, text='双击 MAC 复制 · 右键更多操作', foreground='#666').pack(side='left', padx=10)
|
||
self.cli_tree.bind('<Double-1>', self._on_tree_double)
|
||
self.cli_tree.bind('<Button-3>', self._on_client_menu)
|
||
self.cli_tree.bind('<<TreeviewSelect>>', self._on_client_select)
|
||
|
||
# -- 保留地址页 --
|
||
st_frame = ttk.Frame(nb, padding=4)
|
||
nb.add(st_frame, text=' 保留地址 (MAC→IP) ')
|
||
scol = ('mac', 'ip', 'note', 'hours', 'status')
|
||
shead = ('MAC 地址', '固定 IP', '备注', '租约(小时)', '在线状态')
|
||
swidth = (150, 130, 130, 100, 90)
|
||
self.st_tree = ttk.Treeview(st_frame, columns=scol, show='headings', height=9)
|
||
for c, h, w in zip(scol, shead, swidth):
|
||
self.st_tree.heading(c, text=h)
|
||
self.st_tree.column(c, width=w, anchor='w')
|
||
sysv = ttk.Scrollbar(st_frame, orient='vertical', command=self.st_tree.yview)
|
||
self.st_tree.configure(yscrollcommand=sysv.set)
|
||
self.st_tree.grid(row=0, column=0, sticky='nsew')
|
||
sysv.grid(row=0, column=1, sticky='ns')
|
||
st_frame.rowconfigure(0, weight=1)
|
||
st_frame.columnconfigure(0, weight=1)
|
||
stbar = ttk.Frame(st_frame)
|
||
stbar.grid(row=1, column=0, columnspan=2, sticky='ew', pady=(6, 0))
|
||
ttk.Button(stbar, text='新增保留地址…', command=self._add_static).pack(side='left')
|
||
ttk.Button(stbar, text='编辑选中', command=self._edit_static).pack(side='left', padx=6)
|
||
ttk.Button(stbar, text='删除选中', command=self._del_static).pack(side='left', padx=6)
|
||
ttk.Button(stbar, text='导入 CSV', command=self._import_static).pack(side='left', padx=6)
|
||
ttk.Button(stbar, text='导出 CSV', command=self._export_static).pack(side='left', padx=6)
|
||
ttk.Button(stbar, text='清空列表', command=self._clear_static).pack(side='left', padx=6)
|
||
ttk.Label(st_frame, text='提示: 保留地址立即生效(无需重启服务);「租约(小时)」可为该设备单独指定租期,留空用全局默认;'
|
||
'「在线状态」依据当前租约实时判断。',
|
||
foreground='#666').grid(row=2, column=0, columnspan=2, sticky='w', pady=(6, 0))
|
||
|
||
# === 底部状态栏 ===
|
||
status = ttk.Frame(root, relief='groove', padding=(8, 4))
|
||
status.grid(row=3, column=0, sticky='ew')
|
||
self.stats_label = ttk.Label(status, text='地址池: — · 已分配 0 · 预分配 0 · 冲突 0 · 可用 —')
|
||
self.stats_label.pack(side='left')
|
||
self.pool_canvas = tk.Canvas(status, height=12, width=220, highlightthickness=0, bg='#e8e8e8')
|
||
self.pool_canvas.pack(side='left', padx=12)
|
||
ttk.Label(status, text='监听: UDP 67').pack(side='right')
|
||
|
||
# ---- 状态/辅助 ------------------------------------------------------------
|
||
|
||
def _set_status(self, running, text=None):
|
||
self.status_canvas.delete('all')
|
||
color = '#26a269' if running else '#c01c28'
|
||
self.status_canvas.create_oval(2, 2, 13, 13, fill=color, outline=color)
|
||
|
||
def _set_ui_running(self, running):
|
||
state = 'disabled' if running else 'normal'
|
||
for w in self._config_widgets:
|
||
try:
|
||
w.configure(state=state)
|
||
except tk.TclError:
|
||
pass
|
||
self.btn_start.configure(state='disabled' if running else 'normal')
|
||
self.btn_stop.configure(state='normal' if running else 'disabled')
|
||
self._set_status(running)
|
||
self.status_label.configure(text='服务运行中 · UDP 67' if running else '服务未启动')
|
||
|
||
def _refresh_admin_status(self):
|
||
if is_admin():
|
||
self.admin_label.configure(text='[管理员权限]', foreground='#26a269')
|
||
else:
|
||
self.admin_label.configure(text='[普通权限 · 防火墙放行需管理员]', foreground='#b45800')
|
||
|
||
def _refresh_local_ips(self):
|
||
ips = list_local_ipv4()
|
||
self.e_server_ip.configure(values=ips)
|
||
if ips and not self.e_server_ip.get():
|
||
self.e_server_ip.set(ips[0])
|
||
self._log(f'本机 IPv4 地址: {", ".join(ips) if ips else "未检测到"}', 'info')
|
||
|
||
def _log(self, text, level='info'):
|
||
ts = time.strftime('%H:%M:%S')
|
||
self.txt.configure(state='normal')
|
||
self.txt.insert('end', f'[{ts}] {text}\n', level)
|
||
lines = int(self.txt.index('end-1c').split('.')[0])
|
||
if lines > 3000:
|
||
self.txt.delete('1.0', f'{lines - 2500}.0')
|
||
if self.v_autoscroll.get():
|
||
self.txt.see('end')
|
||
self.txt.configure(state='disabled')
|
||
|
||
def _clear_log(self):
|
||
self.txt.configure(state='normal')
|
||
self.txt.delete('1.0', 'end')
|
||
self.txt.configure(state='disabled')
|
||
|
||
def _clear_conflicts_clicked(self):
|
||
if self.engine and self.engine.running:
|
||
self.engine.clear_conflicts()
|
||
else:
|
||
messagebox.showinfo('提示', '服务未运行')
|
||
|
||
# ---- 配置读写 ------------------------------------------------------------
|
||
|
||
def _collect_config(self):
|
||
def get_ip(widget, name, required=True):
|
||
text = widget.get().strip()
|
||
if not text:
|
||
if required:
|
||
raise ValueError(f'「{name}」不能为空')
|
||
return ''
|
||
try:
|
||
return str(ipaddress.ip_address(text))
|
||
except ValueError:
|
||
raise ValueError(f'「{name}」不是合法 IP: {text}')
|
||
|
||
cfg = {
|
||
'server_ip': get_ip(self.e_server_ip, '服务网卡 IP'),
|
||
'mask': get_ip(self.e_mask, '子网掩码'),
|
||
'pool_start': get_ip(self.e_pool_start, '地址池起始'),
|
||
'pool_end': get_ip(self.e_pool_end, '地址池结束'),
|
||
'gateway': get_ip(self.e_gateway, '默认网关', required=False),
|
||
'dns': [],
|
||
'domain': self.e_domain.get().strip(),
|
||
'lease_hours': 12,
|
||
'tftp': self.e_tftp.get().strip(),
|
||
'bootfile': self.e_bootfile.get().strip(),
|
||
'check_conflict': bool(self.v_conflict.get()),
|
||
'persist_leases': bool(self.v_persist.get()),
|
||
'port': DHCP_SERVER_PORT,
|
||
'static_bindings': self._static_rows(),
|
||
}
|
||
for item in self.e_dns.get().replace(',', ',').replace(' ', ',').split(','):
|
||
item = item.strip()
|
||
if item:
|
||
try:
|
||
cfg['dns'].append(str(ipaddress.ip_address(item)))
|
||
except ValueError:
|
||
raise ValueError(f'DNS 地址不合法: {item}')
|
||
try:
|
||
hours = int(float(self.e_lease.get()))
|
||
if not 1 <= hours <= 8760:
|
||
raise ValueError
|
||
except ValueError:
|
||
raise ValueError('租约时长必须为 1~8760 之间的整数(小时)')
|
||
cfg['lease_hours'] = hours
|
||
if int(ipaddress.ip_address(cfg['pool_start'])) > int(ipaddress.ip_address(cfg['pool_end'])):
|
||
raise ValueError('地址池起始不能大于结束')
|
||
pool_count = int(ipaddress.ip_address(cfg['pool_end'])) - int(ipaddress.ip_address(cfg['pool_start'])) + 1
|
||
if pool_count > 65536:
|
||
raise ValueError('地址池过大(超过 65536 个地址)')
|
||
for row in cfg['static_bindings']:
|
||
ip = row['ip']
|
||
if int(ipaddress.ip_address(ip)) < int(ipaddress.ip_address(cfg['pool_start'])) \
|
||
or int(ipaddress.ip_address(ip)) > int(ipaddress.ip_address(cfg['pool_end'])):
|
||
if not messagebox.askyesno('确认', f'静态绑定 {ip} 不在地址池范围内,'
|
||
f'仍要继续吗?(地址池外绑定是允许的)'):
|
||
raise ValueError('用户取消')
|
||
break
|
||
# 网段一致性提醒
|
||
try:
|
||
net = ipaddress.ip_network(f'{cfg["server_ip"]}/{cfg["mask"]}', strict=False)
|
||
outside = [t for t in (cfg['pool_start'], cfg['pool_end'], cfg['gateway'])
|
||
if t and ipaddress.ip_address(t) not in net]
|
||
if outside:
|
||
if not messagebox.askyesno('网段提醒',
|
||
f'以下地址与服务网卡不在同一网段:\n{", ".join(outside)}\n\n'
|
||
f'是否仍要启动?(客户端可能无法正常通信)'):
|
||
raise ValueError('用户取消')
|
||
except ValueError as exc:
|
||
if str(exc) == '用户取消':
|
||
raise
|
||
return cfg
|
||
|
||
def _static_rows(self):
|
||
rows = []
|
||
for iid in self.st_tree.get_children():
|
||
vals = self.st_tree.item(iid, 'values')
|
||
mac = norm_mac(str(vals[0])) if vals else None
|
||
ip = str(vals[1]).strip() if len(vals) > 1 else ''
|
||
if mac and ip:
|
||
hours = 0
|
||
if len(vals) > 3 and str(vals[3]).strip().isdigit():
|
||
hours = int(str(vals[3]).strip())
|
||
rows.append({'mac': mac, 'ip': ip,
|
||
'note': str(vals[2]) if len(vals) > 2 else '',
|
||
'lease_hours': hours})
|
||
return rows
|
||
|
||
def _sync_static_to_engine(self):
|
||
if self.engine and self.engine.running:
|
||
self.engine.set_static_bindings(self._static_rows())
|
||
|
||
def _save_config(self):
|
||
try:
|
||
cfg = self._collect_config()
|
||
except ValueError as exc:
|
||
messagebox.showwarning('配置有误', str(exc))
|
||
return
|
||
data = dict(cfg)
|
||
data['static_bindings'] = self._static_rows()
|
||
data['saved_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
try:
|
||
with open(self.cfg_path, 'w', encoding='utf-8') as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
except OSError as exc:
|
||
messagebox.showerror('保存失败', f'无法写入配置文件:\n{exc}')
|
||
return
|
||
self._log(f'配置已保存到 {os.path.basename(self.cfg_path)}', 'ok')
|
||
|
||
def _load_config(self):
|
||
defaults = self._default_config()
|
||
data = dict(defaults)
|
||
if os.path.exists(self.cfg_path):
|
||
try:
|
||
with open(self.cfg_path, 'r', encoding='utf-8') as f:
|
||
data.update(json.load(f))
|
||
except Exception as exc:
|
||
self._log(f'读取配置失败, 使用默认值: {exc}', 'warn')
|
||
self.e_server_ip.set(data.get('server_ip', ''))
|
||
self.e_mask.insert(0, data.get('mask', ''))
|
||
self.e_pool_start.insert(0, data.get('pool_start', ''))
|
||
self.e_pool_end.insert(0, data.get('pool_end', ''))
|
||
self.e_gateway.insert(0, data.get('gateway', ''))
|
||
self.e_dns.insert(0, ', '.join(data.get('dns', [])))
|
||
self.e_domain.insert(0, data.get('domain', ''))
|
||
self.e_lease.delete(0, 'end')
|
||
self.e_lease.insert(0, str(data.get('lease_hours', 12)))
|
||
self.e_tftp.insert(0, data.get('tftp', ''))
|
||
self.e_bootfile.insert(0, data.get('bootfile', ''))
|
||
self.v_conflict.set(bool(data.get('check_conflict', True)))
|
||
self.v_persist.set(bool(data.get('persist_leases', True)))
|
||
for item in data.get('static_bindings', []):
|
||
mac = norm_mac(item.get('mac', ''))
|
||
if mac:
|
||
hours = int(item.get('lease_hours') or 0)
|
||
self.st_tree.insert('', 'end', values=(
|
||
mac, item.get('ip', ''), item.get('note', ''),
|
||
hours if hours else ''))
|
||
|
||
def _default_config(self):
|
||
ips = list_local_ipv4()
|
||
base = ips[0] if ips else '192.168.1.10'
|
||
prefix = base.rsplit('.', 1)[0]
|
||
return {
|
||
'server_ip': base,
|
||
'mask': '255.255.255.0',
|
||
'pool_start': f'{prefix}.100',
|
||
'pool_end': f'{prefix}.200',
|
||
'gateway': f'{prefix}.1',
|
||
'dns': ['223.5.5.5', '114.114.114.114'],
|
||
'domain': '',
|
||
'lease_hours': 12,
|
||
'tftp': '',
|
||
'bootfile': '',
|
||
'check_conflict': True,
|
||
'persist_leases': True,
|
||
'static_bindings': [],
|
||
}
|
||
|
||
def _autofill(self):
|
||
ip = self.e_server_ip.get().strip()
|
||
try:
|
||
ipaddress.ip_address(ip)
|
||
except ValueError:
|
||
messagebox.showwarning('提示', '请先填写合法的「服务网卡 IP」')
|
||
return
|
||
prefix = ip.rsplit('.', 1)[0]
|
||
self.e_mask.delete(0, 'end')
|
||
self.e_mask.insert(0, '255.255.255.0')
|
||
self.e_pool_start.delete(0, 'end')
|
||
self.e_pool_start.insert(0, f'{prefix}.100')
|
||
self.e_pool_end.delete(0, 'end')
|
||
self.e_pool_end.insert(0, f'{prefix}.200')
|
||
self.e_gateway.delete(0, 'end')
|
||
self.e_gateway.insert(0, f'{prefix}.1')
|
||
if not self.e_dns.get().strip():
|
||
self.e_dns.insert(0, '223.5.5.5, 114.114.114.114')
|
||
|
||
# ---- 服务控制 ------------------------------------------------------------
|
||
|
||
def _start_server(self):
|
||
if self.engine and self.engine.running:
|
||
return
|
||
try:
|
||
cfg = self._collect_config()
|
||
except ValueError as exc:
|
||
messagebox.showwarning('配置有误', str(exc))
|
||
return
|
||
lease_path = self.lease_path if cfg.get('persist_leases', True) else None
|
||
engine = DhcpServerEngine(cfg, self.events, lease_path=lease_path)
|
||
try:
|
||
engine.start()
|
||
except OSError as exc:
|
||
code = getattr(exc, 'winerror', None) or getattr(exc, 'errno', None)
|
||
hint = ''
|
||
if code in (10013, 10048):
|
||
hint = ('\n\n常见原因: 系统开启了「Internet 连接共享 / 移动热点」,'
|
||
'其内置 DHCP 分配器占用了 UDP 67。\n'
|
||
'可运行: net stop SharedAccess 关闭(需管理员),\n'
|
||
'或用 netstat -ano -p udp | findstr :67 查看占用进程。')
|
||
messagebox.showerror('启动失败', f'{exc}{hint}')
|
||
self._log(f'启动失败: {exc}{hint}', 'err')
|
||
return
|
||
self.engine = engine
|
||
self._set_ui_running(True)
|
||
self._refresh_clients()
|
||
self._refresh_stats()
|
||
|
||
def _stop_server(self):
|
||
if self.engine and self.engine.running:
|
||
self.engine.stop()
|
||
self._set_ui_running(False)
|
||
|
||
# ---- 事件循环 ------------------------------------------------------------
|
||
|
||
def _poll_events(self):
|
||
try:
|
||
while True:
|
||
event = self.events.get_nowait()
|
||
kind = event[0]
|
||
if kind == 'log':
|
||
self._log(event[2], event[1])
|
||
elif kind == 'update':
|
||
self._refresh_clients()
|
||
self._refresh_stats()
|
||
elif kind == 'stopped':
|
||
self._set_ui_running(False)
|
||
except queue.Empty:
|
||
pass
|
||
self.root.after(150, self._poll_events)
|
||
|
||
def _auto_refresh(self):
|
||
if self.engine and self.engine.running:
|
||
self._refresh_clients()
|
||
self._refresh_stats()
|
||
self._refresh_reserved()
|
||
self.root.after(2000, self._auto_refresh)
|
||
|
||
# ---- 客户端列表 ------------------------------------------------------------
|
||
|
||
def _refresh_clients(self):
|
||
tree = self.cli_tree
|
||
selected = {str(tree.item(i)['values'][0]) for i in tree.selection()}
|
||
tree.delete(*tree.get_children())
|
||
if not self.engine:
|
||
return
|
||
q = self.var_query.get().strip().lower()
|
||
state = self.var_state.get()
|
||
for row in self.engine.get_rows():
|
||
if state != '全部' and row['state'] != state:
|
||
continue
|
||
if q and not any(q in str(row.get(k)).lower()
|
||
for k in ('ip', 'mac', 'hostname', 'note')):
|
||
continue
|
||
iid = tree.insert('', 'end', values=(
|
||
row['ip'], row['mac'], row['hostname'] or '—', row['state'],
|
||
row['source'], row['expires'], row['remain'],
|
||
row['note'] or '', row['last_seen']))
|
||
if row['ip'] in selected:
|
||
tree.selection_add(iid)
|
||
if row['_state'] == 'active':
|
||
tree.item(iid, tags=('active',))
|
||
elif row['_state'] == 'offer':
|
||
tree.item(iid, tags=('offer',))
|
||
tree.tag_configure('active', foreground='#1a5fb4')
|
||
tree.tag_configure('offer', foreground='#888888')
|
||
|
||
def _refresh_stats(self):
|
||
if not self.engine:
|
||
self.stats_label.configure(text='地址池: — · 已分配 0 · 预分配 0 · 冲突 0 · 可用 —')
|
||
self._draw_pool_bar(0, 1)
|
||
return
|
||
s = self.engine.stats()
|
||
self.stats_label.configure(
|
||
text=f'地址池: {s["pool_size"]} · 已分配 {s["active"]} · 预分配 {s["offered"]} · '
|
||
f'过期 {s["expired"]} · 冲突 {s["conflicts"]} · 静态绑定 {s["static"]} · 可用 {s["available"]}')
|
||
self._draw_pool_bar(s['active'] + s['offered'], max(1, s['pool_size']))
|
||
|
||
def _draw_pool_bar(self, used, total):
|
||
c = self.pool_canvas
|
||
c.delete('all')
|
||
width = int(c['width'])
|
||
frac = min(1.0, used / total) if total else 0
|
||
c.create_rectangle(0, 0, width, 12, fill='#e8e8e8', outline='')
|
||
c.create_rectangle(0, 0, int(width * frac), 12,
|
||
fill='#c01c28' if frac > 0.9 else '#26a269', outline='')
|
||
c.create_text(width // 2, 6, text=f'{used}/{total}',
|
||
font=('Microsoft YaHei UI', 8), fill='#333')
|
||
|
||
def _remove_selected_lease(self):
|
||
sel = self.cli_tree.selection()
|
||
if not sel:
|
||
messagebox.showinfo('提示', '请先在列表中选择要解除的租约')
|
||
return
|
||
ip = str(self.cli_tree.item(sel[0])['values'][0])
|
||
if self.engine and self.engine.running:
|
||
if messagebox.askyesno('确认', f'确定解除 {ip} 的租约吗?\n该客户端下次续租时会被拒绝。'):
|
||
self.engine.remove_lease(ip)
|
||
else:
|
||
messagebox.showinfo('提示', '服务未运行')
|
||
|
||
# ---- 客户端选择 / 右键 / 保留 -------------------------------------------------
|
||
|
||
def _on_client_select(self, event=None):
|
||
sel = self.cli_tree.selection()
|
||
if not sel:
|
||
self.cli_detail.configure(text='未选择客户端')
|
||
return
|
||
vals = self.cli_tree.item(sel[0])['values']
|
||
ip, mac, host, state = vals[0], vals[1], vals[2], vals[3]
|
||
source, expires, remain = vals[4], vals[5], vals[6]
|
||
note, last = vals[7], vals[8]
|
||
self.cli_detail.configure(
|
||
text=f'{ip} · {mac} · {host} · {state} · {source} · 到期 {expires} · 剩余 {remain} · 最近 {last}'
|
||
+ (f' · 备注 {note}' if str(note) else ''))
|
||
|
||
def _on_client_menu(self, event):
|
||
iid = self.cli_tree.identify_row(event.y)
|
||
if not iid:
|
||
return
|
||
self.cli_tree.selection_set(iid)
|
||
vals = self.cli_tree.item(iid)['values']
|
||
menu = tk.Menu(self.root, tearoff=0)
|
||
menu.add_command(label='复制 MAC', command=lambda: self._copy_text(str(vals[1])))
|
||
menu.add_command(label='复制 IP', command=lambda: self._copy_text(str(vals[0])))
|
||
menu.add_separator()
|
||
menu.add_command(label='解除租约', command=self._remove_selected_lease)
|
||
menu.add_command(label='设为保留地址…', command=self._reserve_selected)
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
def _copy_text(self, text):
|
||
self.root.clipboard_clear()
|
||
self.root.clipboard_append(text)
|
||
self._log(f'已复制: {text}', 'info')
|
||
|
||
def _reserve_selected(self):
|
||
"""把选中客户端直接加入保留地址。"""
|
||
sel = self.cli_tree.selection()
|
||
if not sel:
|
||
messagebox.showinfo('提示', '请先在列表中选择客户端')
|
||
return
|
||
vals = self.cli_tree.item(sel[0])['values']
|
||
mac, ip = str(vals[1]), str(vals[0])
|
||
saved = self._edit_static_dialog({'mac': mac, 'ip': ip, '_title': '设为保留地址'})
|
||
if not saved:
|
||
return
|
||
try:
|
||
self._insert_static_row(**saved)
|
||
except ValueError as exc:
|
||
messagebox.showwarning('提示', str(exc))
|
||
return
|
||
self._sync_static_to_engine()
|
||
self._log(f'已设为保留地址: {saved["mac"]} -> {saved["ip"]}', 'ok')
|
||
self._refresh_clients()
|
||
self._refresh_reserved()
|
||
|
||
def _clear_expired_clicked(self):
|
||
if not (self.engine and self.engine.running):
|
||
messagebox.showinfo('提示', '服务未运行')
|
||
return
|
||
if messagebox.askyesno('确认', '确定清除所有「已过期」状态的租约记录吗?'):
|
||
self.engine.remove_expired()
|
||
self._refresh_clients()
|
||
self._refresh_stats()
|
||
|
||
def _clear_all_clicked(self):
|
||
if not (self.engine and self.engine.running):
|
||
messagebox.showinfo('提示', '服务未运行')
|
||
return
|
||
if messagebox.askyesno('确认', '将清空全部租约记录(所有客户端需重新获取 IP),\n保留地址与冲突名单不受影响。确定吗?'):
|
||
self.engine.clear_all_leases()
|
||
self._refresh_clients()
|
||
self._refresh_stats()
|
||
self._refresh_reserved()
|
||
|
||
def _export_csv(self):
|
||
rows = self.engine.get_rows() if self.engine else []
|
||
if not rows:
|
||
messagebox.showinfo('提示', '当前没有可导出的客户端')
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
defaultextension='.csv', initialfile=f'dhcp客户端-{time.strftime("%Y%m%d-%H%M%S")}.csv',
|
||
filetypes=[('CSV 文件', '*.csv')], parent=self.root)
|
||
if not path:
|
||
return
|
||
try:
|
||
with open(path, 'w', newline='', encoding='utf-8-sig') as f:
|
||
writer = csv.writer(f)
|
||
writer.writerow(['IP 地址', 'MAC 地址', '主机名', '状态', '来源', '租约到期', '剩余有效期'])
|
||
for r in rows:
|
||
writer.writerow([r['ip'], r['mac'], r['hostname'], r['state'],
|
||
r['source'], r['expires'], r['remain']])
|
||
except OSError as exc:
|
||
messagebox.showerror('导出失败', str(exc))
|
||
return
|
||
self._log(f'客户端列表已导出到 {path}', 'ok')
|
||
messagebox.showinfo('导出成功', f'已导出 {len(rows)} 条记录到:\n{path}')
|
||
|
||
def _on_tree_double(self, event):
|
||
region = self.cli_tree.identify('region', event.x, event.y)
|
||
if region != 'cell':
|
||
return
|
||
col = self.cli_tree.identify_column(event.x)
|
||
if col != '#2': # MAC 列
|
||
return
|
||
row = self.cli_tree.identify_row(event.y)
|
||
if row:
|
||
mac = str(self.cli_tree.item(row)['values'][1])
|
||
self.root.clipboard_clear()
|
||
self.root.clipboard_append(mac)
|
||
self._log(f'已复制 MAC: {mac}', 'info')
|
||
|
||
# ---- 保留地址 ------------------------------------------------------------
|
||
|
||
def _edit_static_dialog(self, prefill=None):
|
||
"""保留地址编辑对话框; 返回 {mac, ip, note, lease_hours} 或 None。"""
|
||
prefill = prefill or {}
|
||
dlg = tk.Toplevel(self.root)
|
||
dlg.title(prefill.get('_title') or '新增保留地址')
|
||
dlg.transient(self.root)
|
||
dlg.resizable(False, False)
|
||
frm = ttk.Frame(dlg, padding=12)
|
||
frm.pack(fill='both', expand=True)
|
||
fields = [
|
||
('MAC 地址', prefill.get('mac', '')),
|
||
('固定 IP', prefill.get('ip', '')),
|
||
('备注(可选)', prefill.get('note', '')),
|
||
('租约时长(小时, 可选)', str(prefill.get('lease_hours') or '')),
|
||
]
|
||
entries = {}
|
||
for r, (label, value) in enumerate(fields):
|
||
ttk.Label(frm, text=label).grid(row=r, column=0, sticky='e', padx=(0, 8), pady=4)
|
||
e = ttk.Entry(frm, width=30)
|
||
e.insert(0, value)
|
||
e.grid(row=r, column=1, sticky='ew', pady=4)
|
||
entries[r] = e
|
||
result = {}
|
||
|
||
def on_ok():
|
||
mac = norm_mac(entries[0].get())
|
||
ip = entries[1].get().strip()
|
||
note = entries[2].get().strip()
|
||
hours_text = entries[3].get().strip()
|
||
if not mac:
|
||
messagebox.showwarning('提示', 'MAC 地址格式不正确,示例: AA:BB:CC:DD:EE:FF', parent=dlg)
|
||
return
|
||
try:
|
||
ip = str(ipaddress.ip_address(ip))
|
||
except ValueError:
|
||
messagebox.showwarning('提示', f'IP 地址不合法: {ip}', parent=dlg)
|
||
return
|
||
hours = 0
|
||
if hours_text:
|
||
try:
|
||
hours = int(hours_text)
|
||
if not 1 <= hours <= 8760:
|
||
raise ValueError
|
||
except ValueError:
|
||
messagebox.showwarning('提示', '租约时长必须为 1~8760 的整数小时,或留空使用默认值', parent=dlg)
|
||
return
|
||
result.update({'mac': mac, 'ip': ip, 'note': note, 'lease_hours': hours})
|
||
dlg.destroy()
|
||
|
||
btns = ttk.Frame(frm)
|
||
btns.grid(row=len(fields), column=0, columnspan=2, pady=(10, 0))
|
||
ttk.Button(btns, text='确定', command=on_ok).pack(side='left', padx=6)
|
||
ttk.Button(btns, text='取消', command=dlg.destroy).pack(side='left', padx=6)
|
||
frm.columnconfigure(1, weight=1)
|
||
dlg.grab_set()
|
||
self.root.wait_window(dlg)
|
||
return result or None
|
||
|
||
def _insert_static_row(self, mac, ip, note, lease_hours):
|
||
"""向保留地址表插入一行; 重复或冲突时抛 ValueError。"""
|
||
for row_iid in self.st_tree.get_children():
|
||
vals = self.st_tree.item(row_iid)['values']
|
||
if norm_mac(str(vals[0])) == mac:
|
||
raise ValueError(f'MAC {mac} 已存在保留地址')
|
||
if str(vals[1]) == ip:
|
||
raise ValueError(f'IP {ip} 已被其他保留地址占用')
|
||
if self.engine:
|
||
holder = self.engine.lease_holder(ip)
|
||
if holder and holder[0] != mac and holder[1] in ('active', 'offer'):
|
||
if not messagebox.askyesno(
|
||
'冲突确认',
|
||
f'IP {ip} 当前已被 {holder[0]} 占用租约。\n'
|
||
f'保留给 {mac} 后,原设备续租将被拒绝并改分配其他地址。\n继续吗?'):
|
||
raise ValueError('用户取消')
|
||
self.st_tree.insert('', 'end', values=(
|
||
mac, ip, note, lease_hours if lease_hours else ''))
|
||
|
||
def _add_static(self):
|
||
saved = self._edit_static_dialog()
|
||
if not saved:
|
||
return
|
||
try:
|
||
self._insert_static_row(**saved)
|
||
except ValueError as exc:
|
||
messagebox.showwarning('提示', str(exc))
|
||
return
|
||
self._sync_static_to_engine()
|
||
self._log(f'新增保留地址: {saved["mac"]} -> {saved["ip"]}', 'ok')
|
||
self._refresh_clients()
|
||
self._refresh_reserved()
|
||
|
||
def _edit_static(self):
|
||
sel = self.st_tree.selection()
|
||
if not sel:
|
||
messagebox.showinfo('提示', '请先选中要编辑的保留地址')
|
||
return
|
||
vals = self.st_tree.item(sel[0])['values']
|
||
prefill = {'mac': str(vals[0]), 'ip': str(vals[1]),
|
||
'note': str(vals[2]) if len(vals) > 2 else '',
|
||
'lease_hours': int(str(vals[3])) if len(vals) > 3 and str(vals[3]).isdigit() else 0,
|
||
'_title': '编辑保留地址'}
|
||
saved = self._edit_static_dialog(prefill)
|
||
if not saved:
|
||
return
|
||
old_mac = norm_mac(str(vals[0]))
|
||
if old_mac == saved['mac']:
|
||
# 同一 MAC: 直接原位更新(只允许改 IP/备注/租期)
|
||
self.st_tree.item(sel[0], values=(
|
||
saved['mac'], saved['ip'], saved['note'],
|
||
saved['lease_hours'] if saved['lease_hours'] else ''))
|
||
self._log(f'已更新保留地址: {saved["mac"]} -> {saved["ip"]}', 'ok')
|
||
else:
|
||
try:
|
||
self._insert_static_row(**saved)
|
||
except ValueError as exc:
|
||
messagebox.showwarning('提示', str(exc))
|
||
return
|
||
self.st_tree.delete(sel[0])
|
||
self._sync_static_to_engine()
|
||
self._refresh_clients()
|
||
self._refresh_reserved()
|
||
|
||
def _del_static(self):
|
||
sel = self.st_tree.selection()
|
||
if not sel:
|
||
messagebox.showinfo('提示', '请先选中要删除的保留地址')
|
||
return
|
||
for iid in sel:
|
||
vals = self.st_tree.item(iid)['values']
|
||
self.st_tree.delete(iid)
|
||
self._log(f'删除保留地址: {vals[0]} -> {vals[1]}', 'info')
|
||
self._sync_static_to_engine()
|
||
self._refresh_clients()
|
||
self._refresh_reserved()
|
||
|
||
def _clear_static(self):
|
||
if not self.st_tree.get_children():
|
||
messagebox.showinfo('提示', '保留地址列表为空')
|
||
return
|
||
if not messagebox.askyesno('确认', '确定清空所有保留地址吗?(不影响当前租约)'):
|
||
return
|
||
n = len(self.st_tree.get_children())
|
||
self.st_tree.delete(*self.st_tree.get_children())
|
||
self._sync_static_to_engine()
|
||
self._log(f'已清空 {n} 条保留地址', 'warn')
|
||
self._refresh_reserved()
|
||
|
||
def _export_static(self):
|
||
rows = self._static_rows()
|
||
if not rows:
|
||
messagebox.showinfo('提示', '没有可导出的保留地址')
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
defaultextension='.csv', initialfile=f'保留地址-{time.strftime("%Y%m%d")}.csv',
|
||
filetypes=[('CSV 文件', '*.csv')], parent=self.root)
|
||
if not path:
|
||
return
|
||
try:
|
||
with open(path, 'w', newline='', encoding='utf-8-sig') as f:
|
||
writer = csv.writer(f)
|
||
writer.writerow(['MAC 地址', '固定 IP', '备注', '租约时长(小时)'])
|
||
for r in rows:
|
||
writer.writerow([r['mac'], r['ip'], r['note'],
|
||
r['lease_hours'] if r['lease_hours'] else ''])
|
||
except OSError as exc:
|
||
messagebox.showerror('导出失败', str(exc))
|
||
return
|
||
self._log(f'保留地址已导出到 {path}', 'ok')
|
||
messagebox.showinfo('导出成功', f'已导出 {len(rows)} 条到:\n{path}')
|
||
|
||
def _import_static(self):
|
||
path = filedialog.askopenfilename(
|
||
filetypes=[('CSV 文件', '*.csv')], parent=self.root)
|
||
if not path:
|
||
return
|
||
added, skipped = 0, []
|
||
try:
|
||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.reader(f)
|
||
for row in reader:
|
||
if not row or not row[0].strip():
|
||
continue
|
||
mac = norm_mac(row[0])
|
||
if not mac or len(row) < 2:
|
||
skipped.append(row[0].strip() or '空行')
|
||
continue
|
||
try:
|
||
ip = str(ipaddress.ip_address(row[1].strip()))
|
||
except ValueError:
|
||
skipped.append(f'{mac}(IP不合法)')
|
||
continue
|
||
note = row[2].strip() if len(row) > 2 else ''
|
||
hours = 0
|
||
if len(row) > 3 and row[3].strip().isdigit():
|
||
hours = max(0, min(8760, int(row[3].strip())))
|
||
try:
|
||
self._insert_static_row(mac, ip, note, hours)
|
||
added += 1
|
||
except ValueError as exc:
|
||
skipped.append(str(exc))
|
||
except OSError as exc:
|
||
messagebox.showerror('导入失败', str(exc))
|
||
return
|
||
if added:
|
||
self._sync_static_to_engine()
|
||
self._refresh_reserved()
|
||
self._log(f'保留地址导入完成: 新增 {added} 条', 'ok')
|
||
message = f'导入完成: 新增 {added} 条。'
|
||
if skipped:
|
||
message += f'\n跳过 {len(skipped)} 条:\n' + '\n'.join(skipped[:10])
|
||
messagebox.showinfo('导入结果', message)
|
||
|
||
def _refresh_reserved(self):
|
||
"""刷新保留地址的在线状态列。"""
|
||
summary = {}
|
||
if self.engine and self.engine.running:
|
||
summary = {r['mac']: r['status'] for r in self.engine.get_reserved_summary()}
|
||
for iid in self.st_tree.get_children():
|
||
vals = list(self.st_tree.item(iid)['values'])
|
||
while len(vals) < 5:
|
||
vals.append('')
|
||
mac = norm_mac(str(vals[0]))
|
||
status = summary.get(mac, '')
|
||
vals[4] = status
|
||
self.st_tree.item(iid, values=tuple(vals))
|
||
if status == '在线':
|
||
self.st_tree.item(iid, tags=('online',))
|
||
elif status == '离线':
|
||
self.st_tree.item(iid, tags=('offline',))
|
||
else:
|
||
self.st_tree.item(iid, tags=())
|
||
self.st_tree.tag_configure('online', foreground='#26a269')
|
||
self.st_tree.tag_configure('offline', foreground='#b45800')
|
||
|
||
# ---- 防火墙 ------------------------------------------------------------
|
||
|
||
def _run_netsh(self, args):
|
||
try:
|
||
out = subprocess.run(['netsh', 'advfirewall', 'firewall'] + args,
|
||
capture_output=True, timeout=30)
|
||
text = ((out.stdout or b'') + (out.stderr or b'')).decode('gbk', 'ignore').strip()
|
||
return out.returncode == 0, text
|
||
except Exception as exc:
|
||
return False, str(exc)
|
||
|
||
def _fw_add(self):
|
||
if not is_admin():
|
||
messagebox.showwarning('需要管理员权限',
|
||
'请右键以管理员身份运行本程序(或用管理员打开的终端启动),\n'
|
||
'然后再点击「防火墙放行」。')
|
||
return
|
||
if not messagebox.askyesno(
|
||
'确认操作',
|
||
'即将添加 Windows 防火墙入站规则:\n\n'
|
||
f' 规则名: {FW_RULE_NAME}\n 协议/端口: UDP 67(本机监听端口)\n 动作: 允许\n\n'
|
||
'作用: 允许局域网设备发来的 DHCP 请求进入本机。\n确定添加吗?'):
|
||
return
|
||
ok, text = self._run_netsh(['add', 'rule', f'name={FW_RULE_NAME}', 'dir=in',
|
||
'action=allow', 'protocol=UDP', 'localport=67',
|
||
'profile=any'])
|
||
if ok:
|
||
self._log('防火墙规则已添加: 允许 UDP 67 入站', 'ok')
|
||
messagebox.showinfo('成功', '防火墙规则已添加。')
|
||
else:
|
||
self._log(f'防火墙规则添加失败: {text}', 'err')
|
||
messagebox.showerror('失败', text or 'netsh 执行失败')
|
||
|
||
def _fw_del(self):
|
||
if not is_admin():
|
||
messagebox.showwarning('需要管理员权限', '请以管理员身份运行本程序后再操作。')
|
||
return
|
||
if not messagebox.askyesno('确认操作',
|
||
f'即将删除防火墙入站规则 {FW_RULE_NAME}。\n确定删除吗?'):
|
||
return
|
||
ok, text = self._run_netsh(['delete', 'rule', f'name={FW_RULE_NAME}'])
|
||
if ok:
|
||
self._log('防火墙规则已删除', 'info')
|
||
messagebox.showinfo('成功', '防火墙规则已删除。')
|
||
else:
|
||
self._log(f'防火墙规则删除失败: {text}', 'err')
|
||
messagebox.showerror('失败', text or 'netsh 执行失败')
|
||
|
||
# ---- 退出 ------------------------------------------------------------
|
||
|
||
def _on_close(self):
|
||
try:
|
||
if self.engine and self.engine.running:
|
||
self.engine.stop('窗口关闭')
|
||
finally:
|
||
self.root.destroy()
|
||
|
||
def run(self):
|
||
self.root.mainloop()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 内置自检 (python dhcp_server_app.py --selftest)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def run_selftest():
|
||
print('=' * 62)
|
||
print('便携 DHCP 服务器 - 内置自检')
|
||
print('=' * 62)
|
||
failures = []
|
||
|
||
def check(name, cond, detail=''):
|
||
mark = 'PASS' if cond else 'FAIL'
|
||
print(f' [{mark}] {name}' + (f' ({detail})' if detail else ''))
|
||
if not cond:
|
||
failures.append(name)
|
||
|
||
# ---- 1. 报文解析 ----
|
||
pkt = make_client_packet(MSG_DISCOVER, 'AA:BB:CC:DD:EE:01', 0x1234ABCD,
|
||
hostname='TEST-PC')
|
||
req = parse_dhcp_packet(pkt)
|
||
check('解析: 识别 DISCOVER', req is not None and
|
||
req.options.get(OPT_MSG_TYPE) == bytes([MSG_DISCOVER]))
|
||
check('解析: MAC 地址', req is not None and req.chaddr_mac == 'AA:BB:CC:DD:EE:01')
|
||
check('解析: 事务 ID', req is not None and req.xid == 0x1234ABCD)
|
||
check('解析: 主机名', req is not None and req.hostname == 'TEST-PC')
|
||
check('解析: 客户端标识', req is not None and req.client_id_mac == 'AA:BB:CC:DD:EE:01')
|
||
|
||
# ---- 2. 应答构造 ----
|
||
cfg = {'server_ip': '192.168.10.1', 'mask': '255.255.255.0',
|
||
'pool_start': '192.168.10.100', 'pool_end': '192.168.10.102',
|
||
'gateway': '192.168.10.1', 'dns': ['223.5.5.5'], 'domain': 'test',
|
||
'lease_hours': 2, 'tftp': '', 'bootfile': ''}
|
||
reply = build_reply(req, MSG_OFFER, '192.168.10.100', cfg, 7200)
|
||
r2 = parse_dhcp_packet(reply)
|
||
check('构造: OFFER 可被解析', r2 is not None)
|
||
check('构造: yiaddr', r2 is not None and r2.yiaddr == '192.168.10.100')
|
||
check('构造: 报文类型', r2 is not None and
|
||
r2.options.get(OPT_MSG_TYPE) == bytes([MSG_OFFER]))
|
||
check('构造: 服务器标识', r2 is not None and
|
||
parse_ip_option(r2.options.get(OPT_SERVER_ID)) == '192.168.10.1')
|
||
check('构造: 租约时长', r2 is not None and
|
||
struct.unpack('!I', r2.options.get(OPT_LEASE_TIME))[0] == 7200)
|
||
check('构造: 最小长度', len(reply) >= 300)
|
||
|
||
# ---- 3. 端到端交互(高位端口, 不需要管理员) ----
|
||
test_port, test_client_port = 1067, 1068
|
||
cfg2 = {'server_ip': '127.0.0.1', 'mask': '255.255.255.0',
|
||
'pool_start': '10.77.0.10', 'pool_end': '10.77.0.12',
|
||
'gateway': '10.77.0.1', 'dns': ['223.5.5.5'], 'domain': '',
|
||
'lease_hours': 1, 'tftp': '', 'bootfile': '', 'check_conflict': False,
|
||
'port': test_port, 'static_bindings': []}
|
||
events = queue.Queue()
|
||
engine = DhcpServerEngine(cfg2, events, lease_path=None)
|
||
engine.start()
|
||
time.sleep(0.2)
|
||
|
||
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
client.bind(('127.0.0.1', test_client_port))
|
||
client.settimeout(2)
|
||
|
||
def xchg(pkt_bytes):
|
||
client.sendto(pkt_bytes, ('127.0.0.1', test_port))
|
||
try:
|
||
data, _ = client.recvfrom(2048)
|
||
return parse_dhcp_packet(data)
|
||
except socket.timeout:
|
||
return None
|
||
|
||
# 3.1 DISCOVER -> OFFER
|
||
mac1 = 'AA:BB:CC:00:00:01'
|
||
off = xchg(make_client_packet(MSG_DISCOVER, mac1, 0x11110001, hostname='dev-1'))
|
||
check('交互: DISCOVER 得到 OFFER', off is not None and
|
||
off.options.get(OPT_MSG_TYPE) == bytes([MSG_OFFER]))
|
||
ip1 = off.yiaddr if off else None
|
||
check('交互: OFFER 分配了池内 IP', ip1 is not None and ip1.startswith('10.77.0.'), str(ip1))
|
||
|
||
# 3.2 REQUEST -> ACK
|
||
ack = xchg(make_client_packet(MSG_REQUEST, mac1, 0x11110002,
|
||
requested_ip=ip1, server_id='127.0.0.1'))
|
||
check('交互: REQUEST 得到 ACK', ack is not None and
|
||
ack.options.get(OPT_MSG_TYPE) == bytes([MSG_ACK]))
|
||
check('交互: ACK 地址一致', ack is not None and ack.yiaddr == ip1)
|
||
|
||
# 3.3 续租
|
||
ack2 = xchg(make_client_packet(MSG_REQUEST, mac1, 0x11110003, ciaddr=ip1))
|
||
check('交互: 续租得到 ACK', ack2 is not None and
|
||
ack2.options.get(OPT_MSG_TYPE) == bytes([MSG_ACK]))
|
||
|
||
# 3.4 第二个客户端获得不同 IP
|
||
mac2 = 'AA:BB:CC:00:00:02'
|
||
off2 = xchg(make_client_packet(MSG_DISCOVER, mac2, 0x22220001))
|
||
check('交互: 第二客户端获得不同 IP', off2 is not None and off2.yiaddr != ip1,
|
||
str(off2.yiaddr if off2 else None))
|
||
|
||
# 3.5 非法请求 -> NAK
|
||
nak = xchg(make_client_packet(MSG_REQUEST, 'AA:BB:CC:00:00:99', 0x33330001,
|
||
requested_ip='10.99.9.9', server_id='127.0.0.1'))
|
||
check('交互: 非法请求得到 NAK', nak is not None and
|
||
nak.options.get(OPT_MSG_TYPE) == bytes([MSG_NAK]))
|
||
|
||
# 3.6 DECLINE -> 冲突名单
|
||
xchg(make_client_packet(MSG_DECLINE, mac2, 0x22220002, requested_ip=off2.yiaddr))
|
||
check('交互: DECLINE 加入冲突名单', off2 is not None and off2.yiaddr in engine.conflicts)
|
||
|
||
# 3.7 RELEASE
|
||
xchg(make_client_packet(MSG_RELEASE, mac1, 0x11110004, ciaddr=ip1))
|
||
rows = {r['ip'] for r in engine.get_rows()}
|
||
check('交互: RELEASE 后租约移除', ip1 not in rows)
|
||
|
||
# 3.8 静态绑定 + 自定义租约
|
||
engine.set_static_bindings([{'mac': 'AA:BB:CC:00:00:55', 'ip': '10.77.0.50',
|
||
'note': 'camera', 'lease_hours': 2}])
|
||
off3 = xchg(make_client_packet(MSG_DISCOVER, 'AA:BB:CC:00:00:55', 0x44440001))
|
||
check('交互: 静态绑定生效', off3 is not None and off3.yiaddr == '10.77.0.50')
|
||
check('交互: 保留地址自定义租期(2h)', off3 is not None and
|
||
struct.unpack('!I', off3.options.get(OPT_LEASE_TIME))[0] == 7200)
|
||
check('交互: 保留地址状态查询', any(
|
||
r['mac'] == 'AA:BB:CC:00:00:55' for r in engine.get_reserved_summary()))
|
||
|
||
# 3.9 地址池耗尽 (池 3 个: 10.77.0.10~12, 其中 .11 已加入冲突名单)
|
||
got = []
|
||
for i in range(6):
|
||
rep = xchg(make_client_packet(MSG_DISCOVER, f'AA:55:00:00:00:{i:02X}', 0x55550000 + i))
|
||
if rep is not None and rep.options.get(OPT_MSG_TYPE) == bytes([MSG_OFFER]):
|
||
got.append(rep.yiaddr)
|
||
unique = set(got)
|
||
check('交互: 池耗尽时不再分配重复地址', len(unique) == len(got) and len(got) <= 3,
|
||
f'分配 {len(got)} 个: {sorted(unique)}')
|
||
|
||
# 3.10 统计
|
||
st = engine.stats()
|
||
check('统计: 池大小', st['pool_size'] == 3, str(st))
|
||
check('统计: 可用数不为负', st['available'] >= 0, str(st))
|
||
|
||
# 3.11 批量租约管理
|
||
engine._set_lease('10.77.0.12', 'AA:BB:CC:00:00:77', 'stale', 'expired', 'dynamic')
|
||
n_expired = engine.remove_expired()
|
||
check('管理: 清除过期租约', n_expired >= 1, str(n_expired))
|
||
n_clear = engine.clear_all_leases()
|
||
check('管理: 清空全部租约', n_clear >= 0, str(n_clear))
|
||
|
||
client.close()
|
||
engine.stop('自检结束')
|
||
while not events.empty():
|
||
events.get_nowait()
|
||
|
||
print('-' * 62)
|
||
if failures:
|
||
print(f'自检结果: {len(failures)} 项失败 -> {", ".join(failures)}')
|
||
return 1
|
||
print('自检结果: 全部通过')
|
||
return 0
|
||
|
||
|
||
def main():
|
||
if '--selftest' in sys.argv:
|
||
sys.exit(run_selftest())
|
||
if '--version' in sys.argv:
|
||
print(APP_TITLE)
|
||
return
|
||
app = DhcpGui()
|
||
app.run()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|