Skip to content

Commit d3fb494

Browse files
committed
feat(agent,teacher): prefill static IPv4 dialog from ipv4_detail in register/heartbeat
1 parent 91d1ae9 commit d3fb494

4 files changed

Lines changed: 408 additions & 1 deletion

File tree

agent/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from agent.network_config import (
4040
apply_ipv4_dhcp,
4141
apply_ipv4_static,
42+
get_default_ipv4_detail_snapshot,
4243
get_default_ipv4_interface_name,
4344
is_admin,
4445
)
@@ -192,6 +193,7 @@ def _register(self, conn: socket.socket) -> bool:
192193
"hostname": socket.gethostname(),
193194
"machine_id": self.machine_id,
194195
"ipv4": _guess_report_ipv4(),
196+
"ipv4_detail": get_default_ipv4_detail_snapshot(),
195197
"os_version": _get_os_version(),
196198
"agent_version": AGENT_VERSION,
197199
# Keep legacy key for backward compatibility with old teacher builds.
@@ -403,6 +405,7 @@ def _heartbeat_loop(self) -> None:
403405
"type": MSG_HEARTBEAT,
404406
"hostname": socket.gethostname(),
405407
"ipv4": _guess_report_ipv4(),
408+
"ipv4_detail": get_default_ipv4_detail_snapshot(),
406409
}
407410
)
408411
except (ConnectionError, OSError, ValueError):

agent/network_config.py

Lines changed: 352 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
from __future__ import annotations
44

5+
import json
56
import re
7+
import socket
68
import subprocess
79
import sys
8-
from typing import Dict, List, Optional, Tuple
10+
import time
11+
from typing import Any, Dict, List, Optional, Tuple
912

1013
from common.wincli_escape import netsh_interface_name_arg
1114

@@ -87,6 +90,18 @@ def _clean_wmic_value(s: str) -> str:
8790
return s
8891

8992

93+
def _ipv4_gateway_from_wmi_default_gateway(raw: str) -> str:
94+
"""DefaultIPGateway 可能同时含 IPv4 与 IPv6(如 Win7 fe80);取第一个可用的 IPv4。"""
95+
for t in _wmic_multivalue_tokens(raw or ""):
96+
t = t.strip()
97+
if _is_dotted_ipv4(t) and not _is_ipv4_link_local(t):
98+
return t
99+
g = _clean_wmic_value(raw or "").strip()
100+
if _is_dotted_ipv4(g) and not _is_ipv4_link_local(g):
101+
return g
102+
return ""
103+
104+
90105
def _wmic_multivalue_tokens(s: str) -> List[str]:
91106
"""展开 WMIC 里逗号/花括号数组字段(如 IPAddress),保留全部原子值供匹配。"""
92107
s = (s or "").strip()
@@ -669,3 +684,339 @@ def apply_ipv4_static(
669684
return True, "static ipv4 ok (connection may drop briefly)"
670685
except OSError as e:
671686
return False, str(e)
687+
688+
689+
def _outbound_ipv4_hint() -> str:
690+
"""与 Agent 上报 IPv4 相同的启发式:连向公网时选用的本机源地址。"""
691+
try:
692+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
693+
s.settimeout(1.0)
694+
s.connect(("8.8.8.8", 80))
695+
ip = s.getsockname()[0]
696+
s.close()
697+
return ip
698+
except OSError:
699+
return ""
700+
701+
702+
def _is_dotted_ipv4(s: str) -> bool:
703+
return bool(re.match(r"^\d{1,3}(\.\d{1,3}){3}$", (s or "").strip()))
704+
705+
706+
def _is_ipv4_link_local(s: str) -> bool:
707+
return (s or "").strip().lower().startswith("169.254.")
708+
709+
710+
def _wmic_interface_index_for_netconnection_id(target: str) -> Optional[int]:
711+
"""NetConnectionID(netsh 接口名)→ Win32_NetworkAdapter.InterfaceIndex。"""
712+
want = (target or "").strip().lower()
713+
if not want:
714+
return None
715+
try:
716+
p = subprocess.run(
717+
[
718+
"wmic",
719+
"path",
720+
"Win32_NetworkAdapter",
721+
"where",
722+
"NetEnabled=true",
723+
"get",
724+
"NetConnectionID,InterfaceIndex",
725+
"/format:list",
726+
],
727+
capture_output=True,
728+
text=True,
729+
creationflags=_subprocess_flags(),
730+
timeout=30,
731+
)
732+
except (OSError, subprocess.TimeoutExpired):
733+
return None
734+
if p.returncode != 0:
735+
return None
736+
for row in _parse_wmic_list(p.stdout or ""):
737+
nid = row.get("netconnectionid", "").strip()
738+
m = re.search(r"\d+", row.get("interfaceindex", ""))
739+
if not nid or not m:
740+
continue
741+
if nid.lower() == want:
742+
return int(m.group())
743+
return None
744+
745+
746+
def _wmic_nic_configuration_by_interface_index(if_idx: int) -> Optional[Dict[str, str]]:
747+
try:
748+
p = subprocess.run(
749+
[
750+
"wmic",
751+
"path",
752+
"Win32_NetworkAdapterConfiguration",
753+
"where",
754+
"InterfaceIndex=%d" % if_idx,
755+
"get",
756+
"IPAddress,IPSubnet,DefaultIPGateway,DNSServerSearchOrder,DHCPEnabled,IPEnabled",
757+
"/format:list",
758+
],
759+
capture_output=True,
760+
text=True,
761+
creationflags=_subprocess_flags(),
762+
timeout=30,
763+
)
764+
except (OSError, subprocess.TimeoutExpired):
765+
return None
766+
if p.returncode != 0:
767+
return None
768+
rows = _parse_wmic_list(p.stdout or "")
769+
for row in rows:
770+
if row.get("ipenabled", "").strip().lower() in ("true", "1"):
771+
return row
772+
return rows[0] if rows else None
773+
774+
775+
def _ipv4_subnet_pairs_from_wmi_row(row: Dict[str, str]) -> List[Tuple[str, str]]:
776+
"""
777+
WMI 中 IPAddress / IPSubnet 按下标对齐,但可能夹杂 IPv6。
778+
对每条 IPv4 优先取同下标的点分掩码;否则按第 j 条 IPv4 对应 subs 中第 j 个点分掩码。
779+
"""
780+
ips_t = _wmic_multivalue_tokens(row.get("ipaddress", ""))
781+
subs_t = _wmic_multivalue_tokens(row.get("ipsubnet", ""))
782+
dotted_masks = [s.strip() for s in subs_t if _is_dotted_ipv4(s.strip())]
783+
v4_idx: List[int] = []
784+
for i, ip in enumerate(ips_t):
785+
ip = ip.strip()
786+
if _is_dotted_ipv4(ip) and not _is_ipv4_link_local(ip):
787+
v4_idx.append(i)
788+
out: List[Tuple[str, str]] = []
789+
for j, i in enumerate(v4_idx):
790+
ip = ips_t[i].strip()
791+
sub = ""
792+
if i < len(subs_t):
793+
cand = subs_t[i].strip()
794+
if _is_dotted_ipv4(cand):
795+
sub = cand
796+
if not sub and j < len(dotted_masks):
797+
sub = dotted_masks[j]
798+
out.append((ip, sub))
799+
return out
800+
801+
802+
def _wmic_default_route_interface_index() -> Optional[int]:
803+
"""与 _interface_from_wmi_ip4_route_table 相同:默认 IPv4 路由所在 InterfaceIndex。"""
804+
try:
805+
p = subprocess.run(
806+
[
807+
"wmic",
808+
"path",
809+
"Win32_IP4RouteTable",
810+
"where",
811+
"Destination='0.0.0.0' and Mask='0.0.0.0'",
812+
"get",
813+
"InterfaceIndex,Metric1",
814+
"/format:list",
815+
],
816+
capture_output=True,
817+
text=True,
818+
creationflags=_subprocess_flags(),
819+
timeout=30,
820+
)
821+
except (OSError, subprocess.TimeoutExpired):
822+
return None
823+
if p.returncode != 0:
824+
return None
825+
candidates: List[Tuple[int, int]] = []
826+
for row in _parse_wmic_list(p.stdout or ""):
827+
m = re.search(r"\d+", row.get("interfaceindex", ""))
828+
if not m:
829+
continue
830+
idx = int(m.group())
831+
met_s = row.get("metric1", "").strip()
832+
try:
833+
metric = int(met_s) if met_s.isdigit() else 9999
834+
except ValueError:
835+
metric = 9999
836+
candidates.append((metric, idx))
837+
if not candidates:
838+
return None
839+
candidates.sort(key=lambda x: x[0])
840+
return candidates[0][1]
841+
842+
843+
def _detail_row_to_dict(
844+
row: Dict[str, str], preferred_ip: str
845+
) -> Dict[str, Any]:
846+
pairs = _ipv4_subnet_pairs_from_wmi_row(row)
847+
pref = (preferred_ip or "").strip().lower()
848+
chosen_ip, chosen_mask = "", ""
849+
for ip, sub in pairs:
850+
if pref and ip.lower() == pref:
851+
chosen_ip, chosen_mask = ip, sub
852+
break
853+
if not chosen_ip and pairs:
854+
chosen_ip, chosen_mask = pairs[0]
855+
gw = _ipv4_gateway_from_wmi_default_gateway(row.get("defaultipgateway", ""))
856+
if not gw or gw.upper() == "NULL":
857+
gw = ""
858+
dns_tokens = _wmic_multivalue_tokens(row.get("dnsserversearchorder", ""))
859+
dns_v4: List[str] = []
860+
for t in dns_tokens:
861+
t = t.strip()
862+
if _is_dotted_ipv4(t):
863+
dns_v4.append(t)
864+
dhcp_raw = row.get("dhcpenabled", "").strip().lower()
865+
dhcp = dhcp_raw in ("true", "1")
866+
return {
867+
"ip": chosen_ip,
868+
"mask": chosen_mask,
869+
"gateway": gw,
870+
"dns_primary": dns_v4[0] if dns_v4 else "",
871+
"dns_secondary": dns_v4[1] if len(dns_v4) > 1 else "",
872+
"dhcp": dhcp,
873+
}
874+
875+
876+
def _powershell_ipv4_detail_by_default_route() -> Optional[Dict[str, Any]]:
877+
"""
878+
用默认路由 InterfaceIndex 取 IPv4 地址/前缀/网关/DNS,不依赖 NetConnectionID 与 InterfaceAlias 字符串一致。
879+
"""
880+
if not _powershell_has_get_net_route_cmdlet():
881+
return None
882+
ps = (
883+
"$r = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 "
884+
"-ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1; "
885+
"if (-not $r) { Write-Output '{}'; exit 0 }; "
886+
"$idx = $r.InterfaceIndex; "
887+
"$n = Get-NetIPConfiguration -InterfaceIndex $idx -ErrorAction SilentlyContinue; "
888+
"$ips = @(Get-NetIPAddress -InterfaceIndex $idx -AddressFamily IPv4 "
889+
"-ErrorAction SilentlyContinue | Where-Object { "
890+
"$_.IPAddress -notlike '169.254.*' -and $_.IPAddress -ne '127.0.0.1' } "
891+
"| Sort-Object SkipAsSource,InterfaceMetric); "
892+
"$pick = $ips | Select-Object -First 1; "
893+
"if (-not $pick) { Write-Output '{}'; exit 0 }; "
894+
"$gw = ''; "
895+
"if ($n -and $n.IPv4DefaultGateway -and $n.IPv4DefaultGateway.NextHop) "
896+
"{ $gw = ([string]$n.IPv4DefaultGateway.NextHop).Trim() }; "
897+
"$dns = @(Get-DnsClientServerAddress -InterfaceIndex $idx -AddressFamily IPv4 "
898+
"-ErrorAction SilentlyContinue | Select-Object -ExpandProperty ServerAddresses "
899+
"| Where-Object { $_ -match '^\\d+\\.\\d+\\.\\d+\\.\\d+$' }); "
900+
"if ($dns.Count -lt 1 -and $n -and $n.DNSServer) { "
901+
"foreach ($s in @($n.DNSServer)) { "
902+
"if ($s.AddressFamily -eq 'IPv4' -or $s.AddressFamily -eq 2) "
903+
"{ $dns = @($s.ServerAddresses | Where-Object { $_ -match '^\\d+\\.\\d+\\.\\d+\\.\\d+$' }); break } } }; "
904+
"$d1 = ''; $d2 = ''; "
905+
"if ($dns.Count -ge 1) { $d1 = [string]$dns[0] }; "
906+
"if ($dns.Count -ge 2) { $d2 = [string]$dns[1] }; "
907+
"$dhcp = ($pick.PrefixOrigin -eq 'Dhcp'); "
908+
"$pfx = [int]$pick.PrefixLength; "
909+
"$o = @{ ok=$true; ip=$pick.IPAddress; prefix=$pfx; gateway=$gw; "
910+
"dns_primary=$d1; dns_secondary=$d2; dhcp=$dhcp }; "
911+
"$o | ConvertTo-Json -Compress"
912+
)
913+
try:
914+
p = subprocess.run(
915+
[
916+
"powershell",
917+
"-NoProfile",
918+
"-NonInteractive",
919+
"-Command",
920+
ps,
921+
],
922+
capture_output=True,
923+
text=True,
924+
creationflags=_subprocess_flags(),
925+
timeout=45,
926+
)
927+
except (OSError, subprocess.TimeoutExpired, ValueError):
928+
return None
929+
if p.returncode != 0:
930+
return None
931+
raw = (p.stdout or "").strip()
932+
if not raw:
933+
return None
934+
try:
935+
data = json.loads(raw)
936+
except json.JSONDecodeError:
937+
return None
938+
if not isinstance(data, dict) or not data.get("ok"):
939+
return None
940+
prefix = data.get("prefix")
941+
mask = ""
942+
if prefix is not None:
943+
try:
944+
m = mask_from_prefix(str(int(prefix)))
945+
if m:
946+
mask = m
947+
except (TypeError, ValueError):
948+
pass
949+
return {
950+
"ip": str(data.get("ip") or "").strip(),
951+
"mask": mask,
952+
"gateway": str(data.get("gateway") or "").strip(),
953+
"dns_primary": str(data.get("dns_primary") or "").strip(),
954+
"dns_secondary": str(data.get("dns_secondary") or "").strip(),
955+
"dhcp": bool(data.get("dhcp")),
956+
}
957+
958+
959+
def _merge_ipv4_detail_fields(base: Dict[str, Any], add: Dict[str, Any]) -> None:
960+
"""把 add 中非空字段补入 base;dhcp 任一为真则标为 DHCP。"""
961+
for k in ("ip", "mask", "gateway", "dns_primary", "dns_secondary"):
962+
v = str(add.get(k) or "").strip()
963+
if v and not str(base.get(k) or "").strip():
964+
base[k] = v
965+
if "dhcp" in add:
966+
base["dhcp"] = bool(base.get("dhcp")) or bool(add.get("dhcp"))
967+
968+
969+
_detail_mono = 0.0
970+
_detail_payload: Dict[str, Any] = {}
971+
972+
973+
def get_default_ipv4_detail_snapshot(ttl_sec: float = 20.0) -> Dict[str, Any]:
974+
"""
975+
默认出口网卡的当前 IPv4 参数,供教师端静态表单预填。
976+
优先用 PowerShell(默认路由 InterfaceIndex,与网卡显示名无关),再用 WMI 补缺;带短时缓存。
977+
"""
978+
global _detail_mono, _detail_payload
979+
now = time.monotonic()
980+
if _detail_payload and (now - _detail_mono) < ttl_sec:
981+
return dict(_detail_payload)
982+
983+
if sys.platform != "win32":
984+
_detail_mono = now
985+
_detail_payload = {}
986+
return {}
987+
988+
pref = _outbound_ipv4_hint().strip()
989+
990+
out: Dict[str, Any] = {
991+
"ip": "",
992+
"mask": "",
993+
"gateway": "",
994+
"dns_primary": "",
995+
"dns_secondary": "",
996+
"dhcp": False,
997+
}
998+
999+
psd = _powershell_ipv4_detail_by_default_route()
1000+
if psd:
1001+
out.update(psd)
1002+
1003+
widx = _wmic_default_route_interface_index()
1004+
if widx is not None:
1005+
row = _wmic_nic_configuration_by_interface_index(widx)
1006+
if row:
1007+
_merge_ipv4_detail_fields(out, _detail_row_to_dict(row, pref))
1008+
1009+
iface_name, _diag = get_default_ipv4_interface_name()
1010+
if iface_name:
1011+
if_idx = _wmic_interface_index_for_netconnection_id(iface_name)
1012+
if if_idx is not None and (widx is None or if_idx != widx):
1013+
row2 = _wmic_nic_configuration_by_interface_index(if_idx)
1014+
if row2:
1015+
_merge_ipv4_detail_fields(out, _detail_row_to_dict(row2, pref))
1016+
1017+
if not str(out.get("ip") or "").strip() and pref:
1018+
out["ip"] = pref
1019+
1020+
_detail_mono = now
1021+
_detail_payload = dict(out)
1022+
return dict(out)

0 commit comments

Comments
 (0)