# -*- 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'') 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('<>', 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('', self._on_tree_double) self.cli_tree.bind('', self._on_client_menu) self.cli_tree.bind('<>', 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()