Przeglądaj źródła

refactor(auth): 移除来源IP限制,仅保留密码校验

调整鉴权逻辑以适配4G网络场景,同步更新相关文档及客户端进度说明。
yangkaixiang 1 miesiąc temu
rodzic
commit
52deada0e7

+ 0 - 20
agent/internal/auth/auth.go

@@ -2,9 +2,7 @@ package auth
 
 import (
 	"encoding/json"
-	"net"
 	"net/http"
-	"strings"
 
 	"quickip/internal/config"
 	"quickip/internal/model"
@@ -12,11 +10,6 @@ import (
 
 func Middleware(cfg config.Config, next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if !allowedSource(r.RemoteAddr) {
-			writeJSON(w, http.StatusForbidden, model.APIResponse{Code: 1003, Message: "来源 IP 不允许", Data: nil})
-			return
-		}
-
 		password := r.Header.Get("X-Admin-Password")
 		if password == "" {
 			writeJSON(w, http.StatusUnauthorized, model.APIResponse{Code: 1001, Message: "缺少密码", Data: nil})
@@ -30,19 +23,6 @@ func Middleware(cfg config.Config, next http.Handler) http.Handler {
 	})
 }
 
-func allowedSource(remoteAddr string) bool {
-	host, _, err := net.SplitHostPort(remoteAddr)
-	if err != nil {
-		host = remoteAddr
-	}
-	ip := net.ParseIP(strings.TrimSpace(host))
-	if ip == nil {
-		return false
-	}
-	_, subnet, _ := net.ParseCIDR("169.254.0.0/16")
-	return subnet.Contains(ip)
-}
-
 func writeJSON(w http.ResponseWriter, status int, payload model.APIResponse) {
 	w.Header().Set("Content-Type", "application/json")
 	w.WriteHeader(status)

+ 2 - 2
docs/01-总体方案.md

@@ -117,8 +117,8 @@
 ### 5.1 网络访问限制
 
 1. Agent 仅监听 `169.254.100.2`
-2. 仅允许来源 IP 为 `169.254.0.0/16` 的请求访问 HTTP 接口
-3. `LAN2` 接入 4G 路由器后,4G 网络侧不能访问 Agent
+2. 当前阶段暂不限制来源 IP,仅通过密码校验控制访问
+3. `LAN2` 接入 4G 路由器后,4G 网络侧理论上可达 Agent,因此当前阶段要依赖密码保护
 
 ### 5.2 鉴权方式
 

+ 4 - 6
docs/03-通信与HTTP_API.md

@@ -73,7 +73,6 @@ X-Admin-Password: 固定初始化密码
 1. `0`:成功
 2. `1001`:缺少密码
 3. `1002`:密码错误
-4. `1003`:来源 IP 不允许
 5. `2001`:参数错误
 6. `2002`:资源不存在
 7. `3001`:配置校验失败
@@ -460,8 +459,7 @@ X-Admin-Password: 固定初始化密码
 每个 HTTP 请求统一按以下顺序处理:
 
 1. 检查请求是否发送到 `169.254.100.2`
-2. 检查客户端源 IP 是否属于 `169.254.0.0/16`
-3. 读取 `X-Admin-Password`
-4. 校验密码是否正确
-5. 通过后进入业务逻辑
-6. 返回统一 JSON 响应
+2. 读取 `X-Admin-Password`
+3. 校验密码是否正确
+4. 通过后进入业务逻辑
+5. 返回统一 JSON 响应

+ 5 - 0
docs/05-Agent模块设计.md

@@ -124,6 +124,11 @@
 3. 校验 `X-Admin-Password`
 4. 拒绝未授权请求
 
+当前阶段调整:
+
+1. 暂时取消来源 IP 限制
+2. 仅保留密码校验
+
 边界:
 
 1. 该模块只返回通过/失败结果

+ 2 - 3
docs/07-Agent首阶段实现清单.md

@@ -149,9 +149,8 @@
 
 最低要求:
 
-1. 检查来源 IP 是否在 `169.254.0.0/16`
-2. 校验 `X-Admin-Password`
-3. 返回统一失败结果
+1. 校验 `X-Admin-Password`
+2. 返回统一失败结果
 
 ### 5.6 `httpserver`
 

+ 34 - 0
windows/QuickIP.Client.sln

@@ -0,0 +1,34 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickIP.Client", "QuickIP.Client\QuickIP.Client.csproj", "{ED9AE150-9FB2-4107-9C60-991591555503}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Debug|x64 = Debug|x64
+		Debug|x86 = Debug|x86
+		Release|Any CPU = Release|Any CPU
+		Release|x64 = Release|x64
+		Release|x86 = Release|x86
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Debug|x64.Build.0 = Debug|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Debug|x86.Build.0 = Debug|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Release|Any CPU.Build.0 = Release|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Release|x64.ActiveCfg = Release|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Release|x64.Build.0 = Release|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Release|x86.ActiveCfg = Release|Any CPU
+		{ED9AE150-9FB2-4107-9C60-991591555503}.Release|x86.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+EndGlobal

+ 9 - 0
windows/QuickIP.Client/App.xaml

@@ -0,0 +1,9 @@
+<Application x:Class="QuickIP.Client.App"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:local="clr-namespace:QuickIP.Client"
+             StartupUri="MainWindow.xaml">
+    <Application.Resources>
+         
+    </Application.Resources>
+</Application>

+ 13 - 0
windows/QuickIP.Client/App.xaml.cs

@@ -0,0 +1,13 @@
+using System.Configuration;
+using System.Data;
+using System.Windows;
+
+namespace QuickIP.Client;
+
+/// <summary>
+/// Interaction logic for App.xaml
+/// </summary>
+public partial class App : Application
+{
+}
+

+ 10 - 0
windows/QuickIP.Client/AssemblyInfo.cs

@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly:ThemeInfo(
+    ResourceDictionaryLocation.None,            //where theme specific resource dictionaries are located
+                                                //(used if a resource is not found in the page,
+                                                // or application resource dictionaries)
+    ResourceDictionaryLocation.SourceAssembly   //where the generic resource dictionary is located
+                                                //(used if a resource is not found in the page,
+                                                // app, or any theme specific resource dictionaries)
+)]

+ 0 - 0
windows/QuickIP.Client/Helpers/.gitkeep


+ 269 - 0
windows/QuickIP.Client/MainWindow.xaml

@@ -0,0 +1,269 @@
+<Window x:Class="QuickIP.Client.MainWindow"
+         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+         mc:Ignorable="d"
+         Title="QuickIP"
+         Height="680"
+         Width="980"
+         MinHeight="640"
+         MinWidth="920"
+         WindowStartupLocation="CenterScreen">
+    <Grid Background="#F5F7FB">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="*" />
+            <RowDefinition Height="Auto" />
+        </Grid.RowDefinitions>
+
+        <Border Grid.Row="0"
+                Margin="24,24,24,16"
+                Padding="24"
+                Background="White"
+                CornerRadius="12">
+            <StackPanel>
+                <TextBlock FontSize="28"
+                           FontWeight="SemiBold"
+                           Foreground="#111827"
+                           Text="QuickIP 连接页" />
+                <TextBlock Margin="0,8,0,0"
+                           FontSize="14"
+                           Foreground="#4B5563"
+                           Text="先选择本机有线网卡,客户端会优先尝试直连管理口;只有直连失败时,才需要切换到维护网络。" />
+            </StackPanel>
+        </Border>
+
+        <Grid Grid.Row="1" Margin="24,0,24,16">
+            <Grid.ColumnDefinitions>
+                <ColumnDefinition Width="2.2*" />
+                <ColumnDefinition Width="1.4*" />
+            </Grid.ColumnDefinitions>
+
+            <Border Grid.Column="0"
+                    Margin="0,0,16,0"
+                    Padding="24"
+                    Background="White"
+                    CornerRadius="12">
+                <Grid>
+                    <Grid.RowDefinitions>
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="*" />
+                    </Grid.RowDefinitions>
+
+                    <TextBlock FontSize="20"
+                               FontWeight="SemiBold"
+                               Foreground="#111827"
+                               Text="本机网卡与连接" />
+
+                    <StackPanel Grid.Row="1" Margin="0,20,0,0">
+                        <TextBlock FontSize="13"
+                                   Foreground="#374151"
+                                   Text="本机有线网卡" />
+                        <ComboBox x:Name="AdapterComboBox"
+                                  Margin="0,8,0,0"
+                                  MinHeight="36"
+                                  DisplayMemberPath="DisplayName"
+                                  SelectionChanged="AdapterComboBox_OnSelectionChanged" />
+                        <Border Margin="0,12,0,0" Padding="12" Background="#F9FAFB" CornerRadius="10">
+                            <StackPanel>
+                                <TextBlock FontSize="12" Foreground="#6B7280" Text="建议选择" />
+                                <TextBlock x:Name="RecommendedAdapterTextBlock"
+                                           Margin="0,8,0,0"
+                                           FontSize="14"
+                                           FontWeight="SemiBold"
+                                           Foreground="#111827"
+                                           Text="-" />
+                                <TextBlock x:Name="RecommendedReasonTextBlock"
+                                           Margin="0,8,0,0"
+                                           FontSize="12"
+                                           Foreground="#4B5563"
+                                           Text="-" />
+                                <TextBlock x:Name="ProbeReasonTextBlock"
+                                           Margin="0,8,0,0"
+                                           FontSize="12"
+                                           Foreground="#4B5563"
+                                           Text="尚未对 169.254.100.2 进行可达性探测。" />
+                            </StackPanel>
+                        </Border>
+                    </StackPanel>
+
+                    <UniformGrid Grid.Row="2" Margin="0,24,0,0" Columns="2">
+                        <Border Margin="0,0,12,12" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                            <StackPanel>
+                                <TextBlock FontSize="12" Foreground="#6B7280" Text="网卡名称 / 级别" />
+                                <TextBlock x:Name="AdapterNameTextBlock" Margin="0,8,0,0" FontSize="16" FontWeight="SemiBold" Foreground="#111827" Text="-" />
+                            </StackPanel>
+                        </Border>
+                        <Border Margin="0,0,0,12" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                            <StackPanel>
+                                <TextBlock FontSize="12" Foreground="#6B7280" Text="链路状态" />
+                                <TextBlock x:Name="AdapterLinkTextBlock" Margin="0,8,0,0" FontSize="16" FontWeight="SemiBold" Foreground="#111827" Text="-" />
+                            </StackPanel>
+                        </Border>
+                        <Border Margin="0,0,12,0" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                            <StackPanel>
+                                <TextBlock FontSize="12" Foreground="#6B7280" Text="当前 IPv4" />
+                                <TextBlock x:Name="AdapterIPv4TextBlock" Margin="0,8,0,0" FontSize="16" FontWeight="SemiBold" Foreground="#111827" Text="-" />
+                            </StackPanel>
+                        </Border>
+                        <Border Margin="0,0,0,0" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                            <StackPanel>
+                                <TextBlock FontSize="12" Foreground="#6B7280" Text="网卡类型" />
+                                <TextBlock x:Name="AdapterTypeTextBlock" Margin="0,8,0,0" FontSize="16" FontWeight="SemiBold" Foreground="#111827" Text="-" />
+                            </StackPanel>
+                        </Border>
+                    </UniformGrid>
+
+                    <Border Grid.Row="2" Margin="0,156,0,0" Padding="16" Background="#ECFDF5" CornerRadius="10" VerticalAlignment="Top">
+                        <StackPanel>
+                            <TextBlock FontSize="12" Foreground="#065F46" Text="管理口探测结果" />
+                            <TextBlock x:Name="AdapterProbeTextBlock"
+                                       Margin="0,8,0,0"
+                                       FontSize="16"
+                                       FontWeight="SemiBold"
+                                       Foreground="#065F46"
+                                       Text="-" />
+                        </StackPanel>
+                    </Border>
+
+                    <Border Grid.Row="3" Margin="0,24,0,0" Padding="16" Background="#EEF2FF" CornerRadius="10">
+                        <StackPanel>
+                            <TextBlock x:Name="StatusTextBlock"
+                                       FontSize="13"
+                                       Foreground="#3730A3"
+                                       TextWrapping="Wrap"
+                                       Text="请选择一块有线网卡。" />
+                            <TextBlock x:Name="DiscoveredDeviceTextBlock"
+                                       Margin="0,10,0,0"
+                                       FontSize="12"
+                                       Foreground="#4B5563"
+                                       TextWrapping="Wrap"
+                                       Text="尚未发现设备。" />
+                        </StackPanel>
+                    </Border>
+                </Grid>
+            </Border>
+
+            <Border Grid.Column="1"
+                    Padding="24"
+                    Background="White"
+                    CornerRadius="12">
+                    <Grid>
+                        <Grid.RowDefinitions>
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="*" />
+                        </Grid.RowDefinitions>
+
+                    <TextBlock FontSize="20"
+                               FontWeight="SemiBold"
+                               Foreground="#111827"
+                               Text="操作" />
+
+                    <Border Grid.Row="1" Margin="0,20,0,0" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                        <StackPanel>
+                            <TextBlock FontSize="13"
+                                       FontWeight="SemiBold"
+                                       Foreground="#111827"
+                                       Text="管理密码(必填)" />
+                            <TextBlock Margin="0,6,0,0"
+                                       FontSize="12"
+                                       Foreground="#6B7280"
+                                       Text="用于连接 Linux Agent。客户端不会在界面上显示默认密码。" />
+                            <Grid Margin="0,10,0,0">
+                                <Grid.ColumnDefinitions>
+                                    <ColumnDefinition Width="*" />
+                                    <ColumnDefinition Width="Auto" />
+                                </Grid.ColumnDefinitions>
+                                <PasswordBox x:Name="PasswordBox"
+                                             MinHeight="38"
+                                             VerticalContentAlignment="Center"
+                                             PasswordChanged="PasswordBox_OnPasswordChanged"
+                                             ToolTip="请输入当前 Agent 使用的管理密码。" />
+                                <TextBox x:Name="PasswordTextBox"
+                                         Visibility="Collapsed"
+                                         MinHeight="38"
+                                         VerticalContentAlignment="Center"
+                                         TextChanged="PasswordTextBox_OnTextChanged"
+                                         ToolTip="请输入当前 Agent 使用的管理密码。" />
+                                <Button x:Name="TogglePasswordVisibilityButton"
+                                        Grid.Column="1"
+                                        Margin="8,0,0,0"
+                                        MinWidth="42"
+                                        Padding="10,0"
+                                        Click="TogglePasswordVisibilityButton_OnClick"
+                                        Content="👁" />
+                            </Grid>
+                            <TextBlock Margin="0,10,0,0"
+                                       FontSize="12"
+                                       Foreground="#6B7280"
+                                       Text="客户端会自动保存并回填你上次输入的密码。" />
+                        </StackPanel>
+                    </Border>
+
+                    <Button x:Name="SwitchMaintenanceButton"
+                            Grid.Row="2"
+                            Margin="0,16,0,0"
+                            MinHeight="42"
+                            Click="SwitchMaintenanceButton_OnClick"
+                            Content="切换到维护网络" />
+
+                    <Button x:Name="DiscoverConnectButton"
+                            Grid.Row="3"
+                            Margin="0,12,0,0"
+                            MinHeight="42"
+                            Click="DiscoverConnectButton_OnClick"
+                            Content="发现并连接" />
+
+                    <Border Grid.Row="4" Margin="0,16,0,0" Padding="12" Background="#FEF3C7" CornerRadius="10">
+                        <TextBlock x:Name="AdminStateTextBlock"
+                                   FontSize="12"
+                                   Foreground="#92400E"
+                                   TextWrapping="Wrap"
+                                   Text="管理员状态:未知" />
+                    </Border>
+
+                    <Border Grid.Row="5" Margin="0,16,0,0" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                        <StackPanel>
+                            <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="当前执行顺序" />
+                            <TextBlock Margin="0,12,0,0" Foreground="#4B5563" Text="1. 选择本机有线网卡" />
+                            <TextBlock Margin="0,8,0,0" Foreground="#4B5563" Text="2. 先尝试直接访问 169.254.100.2" />
+                            <TextBlock Margin="0,8,0,0" Foreground="#4B5563" Text="3. 如果直连失败,再切换到维护网络" />
+                            <TextBlock Margin="0,8,0,0" Foreground="#4B5563" Text="4. 自动带出或输入密码并验证连接" />
+                        </StackPanel>
+                    </Border>
+
+                    <Border Grid.Row="6" Margin="0,16,0,0" Padding="16" Background="#F3F4F6" CornerRadius="10">
+                        <Grid>
+                            <Grid.RowDefinitions>
+                                <RowDefinition Height="Auto" />
+                                <RowDefinition Height="*" />
+                            </Grid.RowDefinitions>
+                            <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="运行日志" />
+                            <ListBox x:Name="EventLogListBox"
+                                     Grid.Row="1"
+                                     Margin="0,12,0,0"
+                                     MinHeight="180" />
+                        </Grid>
+                    </Border>
+                </Grid>
+            </Border>
+        </Grid>
+
+        <Border Grid.Row="2"
+                Margin="24,0,24,24"
+                Padding="16"
+                Background="White"
+                CornerRadius="12">
+            <TextBlock Foreground="#6B7280"
+                       Text="当前版本已支持推荐网卡、本机密码保存、切换到维护网络、UDP 发现和最小 HTTP 连接验证。点击按钮后会在右侧日志里显示详细步骤。" />
+        </Border>
+    </Grid>
+</Window>

+ 354 - 0
windows/QuickIP.Client/MainWindow.xaml.cs

@@ -0,0 +1,354 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using QuickIP.Client.Models;
+using QuickIP.Client.Services;
+
+namespace QuickIP.Client;
+
+public partial class MainWindow : Window
+{
+    private readonly NetworkAdapterService _networkAdapterService = new();
+    private readonly PasswordStoreService _passwordStoreService = new();
+    private readonly NetworkConfigurationService _networkConfigurationService = new();
+    private readonly DiscoveryService _discoveryService = new();
+    private readonly AgentApiService _agentApiService = new();
+    private readonly AdminPrivilegeService _adminPrivilegeService = new();
+    private IReadOnlyList<AdapterInfo> _adapters = [];
+    private bool _isShowingPassword;
+    private bool _suppressPasswordSync;
+
+    public MainWindow()
+    {
+        InitializeComponent();
+        Loaded += MainWindow_OnLoaded;
+    }
+
+    private async void MainWindow_OnLoaded(object sender, RoutedEventArgs e)
+    {
+        await LoadInitialStateAsync();
+    }
+
+    private async Task LoadInitialStateAsync()
+    {
+        AdminStateTextBlock.Text = _adminPrivilegeService.IsAdministrator()
+            ? "管理员状态:当前已以管理员身份运行,可执行本机网卡切换。"
+            : "管理员状态:当前不是管理员运行,切换到维护网络会失败。";
+
+        _adapters = _networkAdapterService.GetEthernetAdapters();
+        await _networkAdapterService.ProbeMaintenanceReachabilityAsync(_adapters);
+        _adapters = _adapters
+            .OrderByDescending(adapter => adapter.RecommendationScore)
+            .ThenBy(adapter => adapter.Name)
+            .ToList();
+        AdapterComboBox.ItemsSource = _adapters;
+
+        var recommendedAdapter = _networkAdapterService.GetRecommendedAdapter(_adapters);
+        if (recommendedAdapter is not null)
+        {
+            RecommendedAdapterTextBlock.Text = $"{recommendedAdapter.Name} ({recommendedAdapter.RecommendationLabel})";
+            RecommendedReasonTextBlock.Text = recommendedAdapter.RecommendationReason;
+            ProbeReasonTextBlock.Text = recommendedAdapter.ProbeReason;
+        }
+        else
+        {
+            RecommendedAdapterTextBlock.Text = "未检测到可用有线网卡";
+            RecommendedReasonTextBlock.Text = "请确认本机是否存在可用的以太网适配器。";
+            ProbeReasonTextBlock.Text = "当前没有可探测的网卡。";
+        }
+
+        var savedPassword = _passwordStoreService.LoadPassword();
+        if (!string.IsNullOrWhiteSpace(savedPassword))
+        {
+            PasswordBox.Password = savedPassword;
+            PasswordTextBox.Text = savedPassword;
+        }
+
+        if (recommendedAdapter is not null)
+        {
+            AdapterComboBox.SelectedItem = recommendedAdapter;
+            UpdateAdapterDetails(recommendedAdapter);
+        }
+        else if (_adapters.Count > 0)
+        {
+            AdapterComboBox.SelectedIndex = 0;
+            UpdateAdapterDetails(_adapters[0]);
+        }
+
+        AppendLog("客户端已加载连接页。", true);
+        UpdateButtonStates();
+    }
+
+    private void AdapterComboBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+    {
+        if (AdapterComboBox.SelectedItem is not AdapterInfo adapter)
+        {
+            UpdateAdapterDetails(null);
+            SetStatus("请选择一块有线网卡。", false);
+            UpdateButtonStates();
+            return;
+        }
+
+        UpdateAdapterDetails(adapter);
+        SetStatus(adapter.HasLink
+            ? $"已选择 {adapter.RecommendationLabel} 网卡,可切换到维护网络。{adapter.RecommendationReason}"
+            : "当前网卡未检测到链路,请检查网线连接。", true);
+
+        UpdateButtonStates();
+    }
+
+    private async void SwitchMaintenanceButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        if (!_adminPrivilegeService.IsAdministrator())
+        {
+            SetStatus("当前程序未以管理员身份运行,无法修改本机网卡。", true);
+            MessageBox.Show(this, "请以管理员身份运行客户端后再切换到维护网络。", "需要管理员权限", MessageBoxButton.OK, MessageBoxImage.Warning);
+            return;
+        }
+
+        if (AdapterComboBox.SelectedItem is not AdapterInfo adapter)
+        {
+            SetStatus("请先选择一块网卡。", true);
+            return;
+        }
+
+        PersistPasswordIfNeeded();
+
+        if (adapter.IsReachableToMaintenance)
+        {
+            SetStatus("当前网卡已经可以直接访问 169.254.100.2,无需切换到维护网络。", true);
+            return;
+        }
+
+        SetBusyState(true);
+        SetStatus("正在切换到维护网络,请稍候。", true);
+        await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.Render);
+
+        try
+        {
+            await _networkConfigurationService.ConfigureMaintenanceNetworkAsync(adapter);
+            SetStatus("已切换到维护网络。", true);
+            await RefreshAdaptersAsync(adapter.Id);
+        }
+        catch (Exception ex)
+        {
+            SetStatus($"切换维护网络失败:{ex.Message}", true);
+            MessageBox.Show(this, ex.Message, "切换维护网络失败", MessageBoxButton.OK, MessageBoxImage.Error);
+        }
+        finally
+        {
+            SetBusyState(false);
+        }
+    }
+
+    private async void DiscoverConnectButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        if (string.IsNullOrWhiteSpace(GetCurrentPassword()))
+        {
+            SetStatus("请输入管理密码。", true);
+            MessageBox.Show(this, "请先在右侧“管理密码(必填)”区域输入密码。", "缺少管理密码", MessageBoxButton.OK, MessageBoxImage.Information);
+            return;
+        }
+
+        PersistPasswordIfNeeded();
+        SetBusyState(true);
+        SetStatus("正在检查当前网卡是否可直接访问管理口。", true);
+        DiscoveredDeviceTextBlock.Text = "尚未发现设备。";
+        await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.Render);
+
+        try
+        {
+            if (AdapterComboBox.SelectedItem is AdapterInfo adapter && adapter.IsReachableToMaintenance)
+            {
+                var directResult = await _agentApiService.CheckHealthAsync("http://169.254.100.2:48888", GetCurrentPassword(), adapter.IPv4Address);
+                if (directResult.Success)
+                {
+                    DiscoveredDeviceTextBlock.Text = $"已直接访问管理口:169.254.100.2 / {adapter.Name}";
+                    SetStatus("连接成功,无需切换本机网卡。", true);
+                    return;
+                }
+
+                if (directResult.StatusCode == 401)
+                {
+                    SetStatus("该网卡可以直连管理口,但管理密码错误。", true);
+                    MessageBox.Show(this, "当前网卡已经可以直连 Agent,但密码校验失败,请确认密码是否正确。", "密码错误", MessageBoxButton.OK, MessageBoxImage.Warning);
+                    return;
+                }
+
+                if (directResult.StatusCode == 403)
+                {
+                    SetStatus("该网卡可以直连管理口,但 Agent 拒绝访问,请确认远端是否还是旧版本。", true);
+                    MessageBox.Show(this, "当前网卡已经可以直连 Agent,但请求被拒绝。请确认 Linux 端是否运行的是最新版本 Agent。", "访问被拒绝", MessageBoxButton.OK, MessageBoxImage.Warning);
+                    return;
+                }
+
+                SetStatus($"该网卡虽可建立连接,但直连 HTTP 校验失败:{directResult.Message}。正在尝试设备发现。", true);
+            }
+
+            SetStatus("当前网卡无法完成直连校验,正在发现设备,请稍候。", true);
+            var selectedAdapter = AdapterComboBox.SelectedItem as AdapterInfo;
+            var device = await _discoveryService.DiscoverAsync(selectedAdapter?.IPv4Address ?? string.Empty);
+            if (device is null)
+            {
+                SetStatus("未发现设备。如果当前网卡不可达,请先切换到维护网络。", true);
+                return;
+            }
+
+            DiscoveredDeviceTextBlock.Text = $"已发现设备:{device.Hostname} / {device.Lan2Ip} / Agent {device.AgentVersion}";
+            SetStatus("已发现设备,正在验证连接。", true);
+
+            var discoveredResult = await _agentApiService.CheckHealthAsync($"http://{device.Lan2Ip}:48888", GetCurrentPassword(), selectedAdapter?.IPv4Address ?? string.Empty);
+            SetStatus(discoveredResult.Success ? "连接成功。" : $"设备已发现,但 HTTP 验证失败:{discoveredResult.Message}", true);
+        }
+        catch (Exception ex)
+        {
+            SetStatus($"连接失败:{ex.Message}", true);
+            MessageBox.Show(this, ex.Message, "连接失败", MessageBoxButton.OK, MessageBoxImage.Error);
+        }
+        finally
+        {
+            SetBusyState(false);
+        }
+    }
+
+    private void PersistPasswordIfNeeded()
+    {
+        var password = GetCurrentPassword();
+        if (!string.IsNullOrWhiteSpace(password))
+        {
+            _passwordStoreService.SavePassword(password);
+            return;
+        }
+    }
+
+    private string GetCurrentPassword()
+    {
+        return _isShowingPassword ? PasswordTextBox.Text : PasswordBox.Password;
+    }
+
+    private void UpdateButtonStates()
+    {
+        var adapter = AdapterComboBox.SelectedItem as AdapterInfo;
+        var hasAdapter = adapter is not null;
+        SwitchMaintenanceButton.IsEnabled = hasAdapter;
+        DiscoverConnectButton.IsEnabled = hasAdapter && adapter!.HasLink;
+    }
+
+    private async Task RefreshAdaptersAsync(string? selectedAdapterId = null)
+    {
+        _adapters = _networkAdapterService.GetEthernetAdapters();
+        await _networkAdapterService.ProbeMaintenanceReachabilityAsync(_adapters);
+        _adapters = _adapters
+            .OrderByDescending(adapter => adapter.RecommendationScore)
+            .ThenBy(adapter => adapter.Name)
+            .ToList();
+        AdapterComboBox.ItemsSource = _adapters;
+
+        var selected = selectedAdapterId is null
+            ? _networkAdapterService.GetRecommendedAdapter(_adapters)
+            : _adapters.FirstOrDefault(adapter => adapter.Id == selectedAdapterId) ?? _networkAdapterService.GetRecommendedAdapter(_adapters);
+
+        if (selected is not null)
+        {
+            AdapterComboBox.SelectedItem = selected;
+            UpdateAdapterDetails(selected);
+            RecommendedAdapterTextBlock.Text = $"{selected.Name} ({selected.RecommendationLabel})";
+            RecommendedReasonTextBlock.Text = selected.RecommendationReason;
+            ProbeReasonTextBlock.Text = selected.ProbeReason;
+        }
+    }
+
+    private void UpdateAdapterDetails(AdapterInfo? adapter)
+    {
+        if (adapter is null)
+        {
+            AdapterNameTextBlock.Text = "-";
+            AdapterLinkTextBlock.Text = "-";
+            AdapterIPv4TextBlock.Text = "-";
+            AdapterTypeTextBlock.Text = "-";
+            AdapterProbeTextBlock.Text = "-";
+            return;
+        }
+
+        AdapterNameTextBlock.Text = $"{adapter.Description} / {adapter.RecommendationLabel}";
+        AdapterLinkTextBlock.Text = adapter.HasLink ? "已连接" : "未连接";
+        AdapterIPv4TextBlock.Text = string.IsNullOrWhiteSpace(adapter.IPv4Address) ? "无" : adapter.IPv4Address;
+        AdapterTypeTextBlock.Text = adapter.Type;
+        AdapterProbeTextBlock.Text = $"{adapter.ProbeStatus} / {adapter.ProbeReason}";
+    }
+
+    private void SetStatus(string message, bool addLog)
+    {
+        StatusTextBlock.Text = message;
+        if (addLog)
+        {
+            AppendLog(message, false);
+        }
+    }
+
+    private void AppendLog(string message, bool isInitial)
+    {
+        var prefix = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
+        EventLogListBox.Items.Add($"[{prefix}] {message}");
+        if (!isInitial)
+        {
+            EventLogListBox.ScrollIntoView(EventLogListBox.Items[^1]);
+        }
+    }
+
+    private void SetBusyState(bool isBusy)
+    {
+        AdapterComboBox.IsEnabled = !isBusy;
+        PasswordBox.IsEnabled = !isBusy;
+        PasswordTextBox.IsEnabled = !isBusy;
+        TogglePasswordVisibilityButton.IsEnabled = !isBusy;
+        SwitchMaintenanceButton.IsEnabled = !isBusy && AdapterComboBox.SelectedItem is AdapterInfo;
+        DiscoverConnectButton.IsEnabled = !isBusy && AdapterComboBox.SelectedItem is AdapterInfo adapter && adapter.HasLink;
+    }
+
+    private void TogglePasswordVisibilityButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        _isShowingPassword = !_isShowingPassword;
+        if (_isShowingPassword)
+        {
+            PasswordTextBox.Text = PasswordBox.Password;
+            PasswordBox.Visibility = Visibility.Collapsed;
+            PasswordTextBox.Visibility = Visibility.Visible;
+            TogglePasswordVisibilityButton.Content = "🙈";
+            PasswordTextBox.Focus();
+            PasswordTextBox.CaretIndex = PasswordTextBox.Text.Length;
+            return;
+        }
+
+        PasswordBox.Password = PasswordTextBox.Text;
+        PasswordTextBox.Visibility = Visibility.Collapsed;
+        PasswordBox.Visibility = Visibility.Visible;
+        TogglePasswordVisibilityButton.Content = "👁";
+        PasswordBox.Focus();
+    }
+
+    private void PasswordBox_OnPasswordChanged(object sender, RoutedEventArgs e)
+    {
+        if (_suppressPasswordSync)
+        {
+            return;
+        }
+
+        _suppressPasswordSync = true;
+        PasswordTextBox.Text = PasswordBox.Password;
+        _suppressPasswordSync = false;
+    }
+
+    private void PasswordTextBox_OnTextChanged(object sender, TextChangedEventArgs e)
+    {
+        if (_suppressPasswordSync)
+        {
+            return;
+        }
+
+        _suppressPasswordSync = true;
+        PasswordBox.Password = PasswordTextBox.Text;
+        _suppressPasswordSync = false;
+    }
+}

+ 0 - 0
windows/QuickIP.Client/Models/.gitkeep


+ 44 - 0
windows/QuickIP.Client/Models/AdapterInfo.cs

@@ -0,0 +1,44 @@
+using System.Net.NetworkInformation;
+
+namespace QuickIP.Client.Models;
+
+public sealed class AdapterInfo
+{
+    public required string Id { get; set; }
+    public required string Name { get; set; }
+    public required string Description { get; set; }
+    public required string Type { get; set; }
+    public required bool IsUp { get; set; }
+    public required bool HasLink { get; set; }
+    public required int RecommendationScore { get; set; }
+    public required string RecommendationLabel { get; set; }
+    public required string RecommendationReason { get; set; }
+    public string IPv4Address { get; set; } = string.Empty;
+    public string ProbeStatus { get; set; } = "未探测";
+    public string ProbeReason { get; set; } = "尚未对 169.254.100.2 进行可达性探测。";
+    public bool IsReachableToMaintenance { get; set; }
+
+    public string DisplayName => $"{RecommendationLabel} | {Name} ({Type}) | {ProbeStatus}";
+
+    public static AdapterInfo FromNetworkInterface(
+        NetworkInterface adapter,
+        string ipv4Address,
+        int recommendationScore,
+        string recommendationLabel,
+        string recommendationReason)
+    {
+        return new AdapterInfo
+        {
+            Id = adapter.Id,
+            Name = adapter.Name,
+            Description = adapter.Description,
+            Type = adapter.NetworkInterfaceType.ToString(),
+            IsUp = adapter.OperationalStatus == OperationalStatus.Up,
+            HasLink = adapter.OperationalStatus == OperationalStatus.Up,
+            RecommendationScore = recommendationScore,
+            RecommendationLabel = recommendationLabel,
+            RecommendationReason = recommendationReason,
+            IPv4Address = ipv4Address,
+        };
+    }
+}

+ 10 - 0
windows/QuickIP.Client/Models/DiscoveredDevice.cs

@@ -0,0 +1,10 @@
+namespace QuickIP.Client.Models;
+
+public sealed class DiscoveredDevice
+{
+    public required string DeviceId { get; init; }
+    public required string Hostname { get; init; }
+    public required string AgentVersion { get; init; }
+    public required string Lan2Ip { get; init; }
+    public required bool AuthRequired { get; init; }
+}

+ 8 - 0
windows/QuickIP.Client/Models/HealthCheckResult.cs

@@ -0,0 +1,8 @@
+namespace QuickIP.Client.Models;
+
+public sealed class HealthCheckResult
+{
+    public bool Success { get; init; }
+    public int? StatusCode { get; init; }
+    public string Message { get; init; } = string.Empty;
+}

+ 11 - 0
windows/QuickIP.Client/QuickIP.Client.csproj

@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net9.0-windows</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <UseWPF>true</UseWPF>
+  </PropertyGroup>
+
+</Project>

+ 12 - 2
windows/QuickIP.Client/README.md

@@ -1,8 +1,8 @@
 # QuickIP.Client
 
-此目录预留给 WPF 客户端项目。
+此目录已初始化为 WPF 客户端项目。
 
-后续建议优先补齐
+当前已具备
 
 1. `QuickIP.Client.csproj`
 2. `App.xaml`
@@ -10,3 +10,13 @@
 4. `Views/`
 5. `ViewModels/`
 6. `Services/`
+7. `Models/`
+8. `Helpers/`
+
+下一步建议优先实现:
+
+1. `MainWindow` 基础布局
+2. 网卡选择与状态展示
+3. 密码保存服务
+4. UDP 发现服务
+5. HTTP API 调用服务

+ 0 - 0
windows/QuickIP.Client/Services/.gitkeep


+ 13 - 0
windows/QuickIP.Client/Services/AdminPrivilegeService.cs

@@ -0,0 +1,13 @@
+using System.Security.Principal;
+
+namespace QuickIP.Client.Services;
+
+public sealed class AdminPrivilegeService
+{
+    public bool IsAdministrator()
+    {
+        using var identity = WindowsIdentity.GetCurrent();
+        var principal = new WindowsPrincipal(identity);
+        return principal.IsInRole(WindowsBuiltInRole.Administrator);
+    }
+}

+ 49 - 0
windows/QuickIP.Client/Services/AgentApiService.cs

@@ -0,0 +1,49 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using QuickIP.Client.Models;
+
+namespace QuickIP.Client.Services;
+
+public sealed class AgentApiService
+{
+    public async Task<HealthCheckResult> CheckHealthAsync(string baseAddress, string password, string localIPv4, CancellationToken cancellationToken = default)
+    {
+        try
+        {
+            using var handler = new SocketsHttpHandler();
+            if (!string.IsNullOrWhiteSpace(localIPv4))
+            {
+                handler.ConnectCallback = async (context, token) =>
+                {
+                    var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+                    socket.Bind(new IPEndPoint(IPAddress.Parse(localIPv4), 0));
+                    await socket.ConnectAsync(context.DnsEndPoint, token);
+                    return new NetworkStream(socket, ownsSocket: true);
+                };
+            }
+
+            using var client = new HttpClient(handler) { BaseAddress = new Uri(baseAddress), Timeout = TimeSpan.FromSeconds(5) };
+            client.DefaultRequestHeaders.Add("X-Admin-Password", password);
+
+            using var response = await client.GetAsync("/api/health", cancellationToken);
+            return new HealthCheckResult
+            {
+                Success = response.IsSuccessStatusCode,
+                StatusCode = (int)response.StatusCode,
+                Message = response.IsSuccessStatusCode
+                    ? "HTTP 健康检查通过。"
+                    : $"HTTP 健康检查返回状态码 {(int)response.StatusCode}。",
+            };
+        }
+        catch (Exception ex)
+        {
+            return new HealthCheckResult
+            {
+                Success = false,
+                StatusCode = null,
+                Message = ex.Message,
+            };
+        }
+    }
+}

+ 73 - 0
windows/QuickIP.Client/Services/DiscoveryService.cs

@@ -0,0 +1,73 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using QuickIP.Client.Models;
+
+namespace QuickIP.Client.Services;
+
+public sealed class DiscoveryService
+{
+    private const int DiscoveryPort = 50000;
+
+    public async Task<DiscoveredDevice?> DiscoverAsync(string localIPv4, CancellationToken cancellationToken = default)
+    {
+        if (string.IsNullOrWhiteSpace(localIPv4))
+        {
+            return null;
+        }
+
+        using var client = new UdpClient(AddressFamily.InterNetwork)
+        {
+            EnableBroadcast = true,
+        };
+
+        client.Client.Bind(new IPEndPoint(IPAddress.Parse(localIPv4), 0));
+        var request = new
+        {
+            protocol_version = 1,
+            message_type = "discover",
+            request_id = Guid.NewGuid().ToString(),
+            client_name = Environment.MachineName,
+        };
+
+        var payload = JsonSerializer.SerializeToUtf8Bytes(request);
+        await client.SendAsync(payload, payload.Length, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort));
+
+        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+        timeoutCts.CancelAfter(TimeSpan.FromSeconds(3));
+
+        try
+        {
+            var result = await client.ReceiveAsync(timeoutCts.Token);
+            var response = JsonSerializer.Deserialize<DiscoveryResponse>(result.Buffer);
+            if (response is null || response.MessageType != "discover_response")
+            {
+                return null;
+            }
+
+            return new DiscoveredDevice
+            {
+                DeviceId = response.DeviceId ?? string.Empty,
+                Hostname = response.Hostname ?? string.Empty,
+                AgentVersion = response.AgentVersion ?? string.Empty,
+                Lan2Ip = response.Lan2Ip ?? string.Empty,
+                AuthRequired = response.AuthRequired,
+            };
+        }
+        catch (OperationCanceledException)
+        {
+            return null;
+        }
+    }
+
+    private sealed class DiscoveryResponse
+    {
+        public string? MessageType { get; set; }
+        public string? DeviceId { get; set; }
+        public string? Hostname { get; set; }
+        public string? AgentVersion { get; set; }
+        public string? Lan2Ip { get; set; }
+        public bool AuthRequired { get; set; }
+    }
+}

+ 174 - 0
windows/QuickIP.Client/Services/NetworkAdapterService.cs

@@ -0,0 +1,174 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using QuickIP.Client.Models;
+
+namespace QuickIP.Client.Services;
+
+public sealed class NetworkAdapterService
+{
+    public IReadOnlyList<AdapterInfo> GetEthernetAdapters()
+    {
+        return NetworkInterface
+            .GetAllNetworkInterfaces()
+            .Where(IsSupportedEthernetAdapter)
+            .Select(BuildAdapterInfo)
+            .OrderByDescending(adapter => adapter.RecommendationScore)
+            .ThenBy(adapter => adapter.Name)
+            .ToList();
+    }
+
+    public AdapterInfo? GetRecommendedAdapter(IReadOnlyList<AdapterInfo> adapters)
+    {
+        return adapters
+            .OrderByDescending(adapter => adapter.RecommendationScore)
+            .ThenBy(adapter => adapter.Name)
+            .FirstOrDefault();
+    }
+
+    public async Task ProbeMaintenanceReachabilityAsync(IReadOnlyList<AdapterInfo> adapters, CancellationToken cancellationToken = default)
+    {
+        foreach (var adapter in adapters)
+        {
+            if (!adapter.HasLink || string.IsNullOrWhiteSpace(adapter.IPv4Address))
+            {
+                adapter.ProbeStatus = "不可达";
+                adapter.ProbeReason = "该网卡当前没有可用的 IPv4 或链路未连接。";
+                adapter.IsReachableToMaintenance = false;
+                continue;
+            }
+
+            var reachable = await CanReachMaintenanceAsync(adapter.IPv4Address, cancellationToken);
+            adapter.IsReachableToMaintenance = reachable;
+            if (reachable)
+            {
+                adapter.ProbeStatus = "可达";
+                adapter.ProbeReason = "已探测到该网卡可以与 169.254.100.2:48888 建立 TCP 连接。";
+                adapter.RecommendationScore += 100;
+                adapter.RecommendationLabel = "推荐";
+                adapter.RecommendationReason = "该网卡已实际探测到可与 Linux 管理口建立连接,优先使用。";
+            }
+            else
+            {
+                adapter.ProbeStatus = "未通";
+                adapter.ProbeReason = "当前无法通过该网卡与 169.254.100.2:48888 建立 TCP 连接。";
+            }
+        }
+    }
+
+    private static bool IsSupportedEthernetAdapter(NetworkInterface adapter)
+    {
+        if (adapter.NetworkInterfaceType is not NetworkInterfaceType.Ethernet and not NetworkInterfaceType.GigabitEthernet)
+        {
+            return false;
+        }
+
+        return adapter.Description.Contains("VMware", System.StringComparison.OrdinalIgnoreCase)
+            || !adapter.Description.Contains("Virtual", System.StringComparison.OrdinalIgnoreCase);
+    }
+
+    private static AdapterInfo BuildAdapterInfo(NetworkInterface adapter)
+    {
+        var ipv4Address = adapter
+            .GetIPProperties()
+            .UnicastAddresses
+            .FirstOrDefault(address => address.Address.AddressFamily == AddressFamily.InterNetwork)
+            ?.Address
+            .ToString() ?? string.Empty;
+
+        var score = GetRecommendationScore(adapter, ipv4Address);
+        var (label, reason) = GetRecommendation(score, adapter, ipv4Address);
+
+        return AdapterInfo.FromNetworkInterface(adapter, ipv4Address, score, label, reason);
+    }
+
+    private static int GetRecommendationScore(NetworkInterface adapter, string ipv4Address)
+    {
+        var score = 0;
+        var description = adapter.Description;
+        var isVirtual = IsVirtualAdapter(description);
+
+        if (adapter.NetworkInterfaceType is NetworkInterfaceType.Ethernet or NetworkInterfaceType.GigabitEthernet)
+        {
+            score += 50;
+        }
+
+        if (adapter.OperationalStatus == OperationalStatus.Up)
+        {
+            score += 40;
+        }
+
+        if (ipv4Address.StartsWith("169.254.", System.StringComparison.Ordinal))
+        {
+            score += 30;
+        }
+
+        if (!isVirtual)
+        {
+            score += 20;
+        }
+
+        if (description.Contains("Intel", System.StringComparison.OrdinalIgnoreCase)
+            || description.Contains("Realtek", System.StringComparison.OrdinalIgnoreCase)
+            || description.Contains("Broadcom", System.StringComparison.OrdinalIgnoreCase))
+        {
+            score += 10;
+        }
+
+        if (isVirtual)
+        {
+            score -= 40;
+        }
+
+        return score;
+    }
+
+    private static (string Label, string Reason) GetRecommendation(int score, NetworkInterface adapter, string ipv4Address)
+    {
+        if (score >= 90)
+        {
+            if (ipv4Address.StartsWith("169.254.", System.StringComparison.Ordinal))
+            {
+                return ("推荐", "已连接且当前 IPv4 为 169.254 网段,最像初始化直连网卡。");
+            }
+
+            return ("推荐", "已检测到已连接的有线网卡,适合作为初始化连接口。");
+        }
+
+        if (score >= 40)
+        {
+            return ("可选", "该网卡可用,但不是最优候选,请确认是否为实际直连接口。");
+        }
+
+        return ("不建议", "该网卡更像虚拟或非直连接口,通常不建议用于设备初始化。");
+    }
+
+    private static bool IsVirtualAdapter(string description)
+    {
+        return description.Contains("VMware", System.StringComparison.OrdinalIgnoreCase)
+            || description.Contains("Virtual", System.StringComparison.OrdinalIgnoreCase)
+            || description.Contains("Hyper-V", System.StringComparison.OrdinalIgnoreCase)
+            || description.Contains("TAP", System.StringComparison.OrdinalIgnoreCase)
+            || description.Contains("VPN", System.StringComparison.OrdinalIgnoreCase)
+            || description.Contains("Loopback", System.StringComparison.OrdinalIgnoreCase);
+    }
+
+    private static async Task<bool> CanReachMaintenanceAsync(string localIPv4, CancellationToken cancellationToken)
+    {
+        try
+        {
+            using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+            socket.Bind(new IPEndPoint(IPAddress.Parse(localIPv4), 0));
+            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(500));
+            await socket.ConnectAsync(IPAddress.Parse("169.254.100.2"), 48888, timeoutCts.Token);
+            return socket.Connected;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+}

+ 58 - 0
windows/QuickIP.Client/Services/NetworkConfigurationService.cs

@@ -0,0 +1,58 @@
+using System.Diagnostics;
+using System.Text;
+using QuickIP.Client.Models;
+
+namespace QuickIP.Client.Services;
+
+public sealed class NetworkConfigurationService
+{
+    public async Task ConfigureMaintenanceNetworkAsync(AdapterInfo adapter, CancellationToken cancellationToken = default)
+    {
+        var escapedName = adapter.Name.Replace("'", "''");
+        var command = "$adapter = Get-NetAdapter -Name '{0}' -ErrorAction Stop; " +
+                      "Remove-NetIPAddress -InterfaceIndex $adapter.IfIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; " +
+                      "Remove-NetRoute -InterfaceIndex $adapter.IfIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; " +
+                      "New-NetIPAddress -InterfaceIndex $adapter.IfIndex -IPAddress 169.254.100.1 -PrefixLength 16 -AddressFamily IPv4 -ErrorAction Stop";
+
+        await RunPowerShellAsync(string.Format(command, escapedName), cancellationToken);
+    }
+
+    private static async Task RunPowerShellAsync(string command, CancellationToken cancellationToken)
+    {
+        var startInfo = new ProcessStartInfo
+        {
+            FileName = "powershell.exe",
+            Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"{command}\"",
+            RedirectStandardError = true,
+            RedirectStandardOutput = true,
+            UseShellExecute = false,
+            CreateNoWindow = true,
+        };
+
+        using var process = new Process { StartInfo = startInfo };
+        process.Start();
+
+        var stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+        var stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken);
+
+        await process.WaitForExitAsync(cancellationToken);
+
+        var stdOut = await stdOutTask;
+        var stdErr = await stdErrTask;
+        if (process.ExitCode != 0)
+        {
+            var message = new StringBuilder();
+            if (!string.IsNullOrWhiteSpace(stdOut))
+            {
+                message.AppendLine(stdOut.Trim());
+            }
+
+            if (!string.IsNullOrWhiteSpace(stdErr))
+            {
+                message.AppendLine(stdErr.Trim());
+            }
+
+            throw new InvalidOperationException(message.ToString().Trim());
+        }
+    }
+}

+ 27 - 0
windows/QuickIP.Client/Services/PasswordStoreService.cs

@@ -0,0 +1,27 @@
+using Microsoft.Win32;
+
+namespace QuickIP.Client.Services;
+
+public sealed class PasswordStoreService
+{
+    private const string RegistryPath = @"Software\QuickIP";
+    private const string ValueName = "SavedPassword";
+
+    public string LoadPassword()
+    {
+        using var key = Registry.CurrentUser.OpenSubKey(RegistryPath, writable: false);
+        return key?.GetValue(ValueName) as string ?? string.Empty;
+    }
+
+    public void SavePassword(string password)
+    {
+        using var key = Registry.CurrentUser.CreateSubKey(RegistryPath);
+        key.SetValue(ValueName, password, RegistryValueKind.String);
+    }
+
+    public void ClearPassword()
+    {
+        using var key = Registry.CurrentUser.OpenSubKey(RegistryPath, writable: true);
+        key?.DeleteValue(ValueName, throwOnMissingValue: false);
+    }
+}

+ 0 - 0
windows/QuickIP.Client/ViewModels/.gitkeep


+ 0 - 0
windows/QuickIP.Client/Views/.gitkeep


+ 10 - 1
windows/README.md

@@ -18,4 +18,13 @@ windows/
     Helpers/
 ```
 
-当前阶段尚未创建 WPF 工程文件,后续将在此目录下补齐客户端骨架。
+当前阶段已完成:
+
+1. `QuickIP.Client.sln`
+2. `QuickIP.Client.csproj`
+3. 基础 WPF 入口文件
+4. `Views/`
+5. `ViewModels/`
+6. `Services/`
+7. `Models/`
+8. `Helpers/`