|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import json |
5 | 6 | import re |
| 7 | +import socket |
6 | 8 | import subprocess |
7 | 9 | import sys |
8 | | -from typing import Dict, List, Optional, Tuple |
| 10 | +import time |
| 11 | +from typing import Any, Dict, List, Optional, Tuple |
9 | 12 |
|
10 | 13 | from common.wincli_escape import netsh_interface_name_arg |
11 | 14 |
|
@@ -87,6 +90,18 @@ def _clean_wmic_value(s: str) -> str: |
87 | 90 | return s |
88 | 91 |
|
89 | 92 |
|
| 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 | + |
90 | 105 | def _wmic_multivalue_tokens(s: str) -> List[str]: |
91 | 106 | """展开 WMIC 里逗号/花括号数组字段(如 IPAddress),保留全部原子值供匹配。""" |
92 | 107 | s = (s or "").strip() |
@@ -669,3 +684,339 @@ def apply_ipv4_static( |
669 | 684 | return True, "static ipv4 ok (connection may drop briefly)" |
670 | 685 | except OSError as e: |
671 | 686 | 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