diff --git a/internal/biz/toolbox_migration.go b/internal/biz/toolbox_migration.go index a40dca977..a2fd12c77 100644 --- a/internal/biz/toolbox_migration.go +++ b/internal/biz/toolbox_migration.go @@ -245,7 +245,13 @@ func (uc *ToolboxMigrationUsecase) Reset() error { if uc.state.step == types.MigrationStepRunning { return errors.New(uc.t.Get("migration is running, cannot reset")) } - uc.state = migrationState{step: types.MigrationStepIdle} + // 逐字段重置,整体赋值会把持有的锁一并换成零值 + uc.state.step = types.MigrationStepIdle + uc.state.connection = nil + uc.state.results = nil + uc.state.logs = nil + uc.state.startedAt = nil + uc.state.endedAt = nil return nil } diff --git a/internal/biz/toolbox_migration_push.go b/internal/biz/toolbox_migration_push.go index 9b2aed608..f777e909a 100644 --- a/internal/biz/toolbox_migration_push.go +++ b/internal/biz/toolbox_migration_push.go @@ -28,12 +28,21 @@ type remoteSetting struct { ProjectPath string `json:"project_path"` } -// probeRemote 校验目标面板连通性 +// probeRemote 校验目标面板连通性并读取其版本 func (uc *ToolboxMigrationUsecase) probeRemote(ctx context.Context, conn *request.ToolboxMigrationConnection) (*types.MigrationSource, error) { - if _, err := uc.remote.Request(ctx, conn, "GET", "/api/home/installed_environment", nil); err != nil { + body, err := uc.remote.Request(ctx, conn, "GET", "/api/home/system_info", nil) + if err != nil { return nil, errors.New(uc.t.Get("failed to connect target server: %v", err)) } - return &types.MigrationSource{Panel: "acepanel"}, nil + var response struct { + Data struct { + PanelVersion string `json:"panel_version"` + } `json:"data"` + } + if err = json.Unmarshal(body, &response); err != nil { + return nil, errors.New(uc.t.Get("failed to connect target server: %v", err)) + } + return &types.MigrationSource{Panel: "acepanel", Version: response.Data.PanelVersion}, nil } // localItems 列出本地可推送到目标面板的资源 diff --git a/pkg/network/netplan.go b/pkg/network/netplan.go index 403189dee..62a9c84ec 100644 --- a/pkg/network/netplan.go +++ b/pkg/network/netplan.go @@ -54,8 +54,8 @@ func (b *netplanBackend) Load(ctx context.Context, items []Interface) error { case len(matches) > 1: items[i].Reason = "multiple netplan interface definitions match this interface" default: - items[i].IPv4 = b.family(matches[0].definition, true, items[i].CurrentIPv4) - items[i].IPv6 = b.family(matches[0].definition, false, items[i].CurrentIPv6) + items[i].IPv4 = b.family(matches[0].definition, true, items[i].CurrentIPv4.Addresses) + items[i].IPv6 = b.family(matches[0].definition, false, items[i].CurrentIPv6.Addresses) items[i].ConfiguredMTU = valueInt(matches[0].definition["mtu"]) items[i].Editable = true } diff --git a/pkg/network/network.go b/pkg/network/network.go index 5822b6414..9463cb425 100644 --- a/pkg/network/network.go +++ b/pkg/network/network.go @@ -43,6 +43,13 @@ type Config struct { IPv6 FamilyConfig `json:"ipv6"` } +// FamilyState 网卡当前生效的网络状态,自动获取时配置里没有这些值,用于回填表单 +type FamilyState struct { + Addresses []string `json:"addresses"` + Gateway string `json:"gateway"` + DNS []string `json:"dns"` +} + type Interface struct { Name string `json:"name"` Type string `json:"type"` @@ -50,8 +57,8 @@ type Interface struct { MAC string `json:"mac"` CurrentMTU int `json:"current_mtu"` ConfiguredMTU int `json:"configured_mtu"` - CurrentIPv4 []string `json:"current_ipv4"` - CurrentIPv6 []string `json:"current_ipv6"` + CurrentIPv4 FamilyState `json:"current_ipv4"` + CurrentIPv6 FamilyState `json:"current_ipv6"` Editable bool `json:"editable"` Reason string `json:"reason"` IPv4 FamilyConfig `json:"ipv4"` @@ -77,7 +84,8 @@ type backend interface { // Service 网卡配置管理,同一时间只允许一个变更处于待确认状态 type Service struct { - mu sync.Mutex + apply sync.Mutex + mu sync.RWMutex backend backend rollback func(context.Context) error timer *time.Timer @@ -92,9 +100,13 @@ func (s *Service) Interfaces(ctx context.Context) (*Result, error) { if err != nil { return nil, err } + ipv4Gateways, ipv6Gateways := defaultGateways(ctx, true), defaultGateways(ctx, false) + for i := range items { + ipv4, ipv6 := currentDNS(ctx, items[i].Name) + items[i].CurrentIPv4.Gateway, items[i].CurrentIPv4.DNS = ipv4Gateways[items[i].Name], ipv4 + items[i].CurrentIPv6.Gateway, items[i].CurrentIPv6.DNS = ipv6Gateways[items[i].Name], ipv6 + } - s.mu.Lock() - defer s.mu.Unlock() current := s.detect(ctx) if current == nil { for i := range items { @@ -105,7 +117,7 @@ func (s *Service) Interfaces(ctx context.Context) (*Result, error) { if err = current.Load(ctx, items); err != nil { return nil, err } - return &Result{Manager: current.Name(), Items: items, Pending: s.rollback != nil}, nil + return &Result{Manager: current.Name(), Items: items, Pending: s.pending()}, nil } // Update 应用网卡配置,成功后进入待确认状态,超时未确认自动回滚 @@ -114,9 +126,9 @@ func (s *Service) Update(ctx context.Context, config Config) error { return err } - s.mu.Lock() - defer s.mu.Unlock() - if s.rollback != nil { + s.apply.Lock() + defer s.apply.Unlock() + if s.pending() { return fmt.Errorf("%w: a previous change is still waiting for confirmation", ErrValidation) } current := s.detect(ctx) @@ -149,40 +161,35 @@ func (s *Service) Update(ctx context.Context, config Config) error { return errors.Join(err, rollback(context.WithoutCancel(ctx))) } + s.mu.Lock() s.rollback = rollback s.timer = time.AfterFunc(ConfirmTimeout, s.expire) + s.mu.Unlock() return nil } func (s *Service) Confirm() error { - s.mu.Lock() - defer s.mu.Unlock() - if s.rollback == nil { + if s.take() == nil { return fmt.Errorf("%w: no change is waiting for confirmation", ErrValidation) } - s.timer.Stop() - s.timer, s.rollback = nil, nil return nil } func (s *Service) Rollback(ctx context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.rollback == nil { + s.apply.Lock() + defer s.apply.Unlock() + rollback := s.take() + if rollback == nil { return fmt.Errorf("%w: no change is waiting for confirmation", ErrValidation) } - s.timer.Stop() - rollback := s.rollback - s.timer, s.rollback = nil, nil return rollback(ctx) } // expire 等待确认超时,自动回滚 func (s *Service) expire() { - s.mu.Lock() - rollback := s.rollback - s.timer, s.rollback = nil, nil - s.mu.Unlock() + s.apply.Lock() + defer s.apply.Unlock() + rollback := s.take() if rollback == nil { return } @@ -191,18 +198,51 @@ func (s *Service) expire() { _ = rollback(ctx) } +// pending 是否有变更等待确认 +func (s *Service) pending() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.rollback != nil +} + +// take 取出待确认的回滚函数并清空状态,返回 nil 表示没有待确认变更 +func (s *Service) take() func(context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + rollback := s.rollback + if rollback != nil { + s.timer.Stop() + s.timer, s.rollback = nil, nil + } + return rollback +} + // detect 探测网络管理器,结果在进程生命周期内缓存 func (s *Service) detect(ctx context.Context) backend { - if s.backend != nil { - return s.backend + s.mu.RLock() + cached := s.backend + s.mu.RUnlock() + if cached != nil { + return cached } + + var found backend for _, candidate := range []backend{&netplanBackend{}, &networkManagerBackend{}, &ifupdownBackend{}} { if candidate.available(ctx) { - s.backend = candidate - return candidate + found = candidate + break } } - return nil + if found == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.backend == nil { + s.backend = found + } + return s.backend } // verify 确认配置已在网卡上生效,挡住应用命令返回成功但实际未生效的情况 @@ -317,8 +357,10 @@ func runtimeInterfaces() ([]Interface, error) { ipv4, ipv6 := currentAddresses(current) items = append(items, Interface{ Name: current.Name, Type: kind, State: state, MAC: current.HardwareAddr.String(), - CurrentMTU: current.MTU, CurrentIPv4: ipv4, CurrentIPv6: ipv6, - IPv4: emptyFamily(), IPv6: emptyFamily(), + CurrentMTU: current.MTU, + CurrentIPv4: FamilyState{Addresses: ipv4}, + CurrentIPv6: FamilyState{Addresses: ipv6}, + IPv4: emptyFamily(), IPv6: emptyFamily(), }) } slices.SortFunc(items, func(a, b Interface) int { return strings.Compare(a.Name, b.Name) }) @@ -379,6 +421,70 @@ func unique(values []string) []string { return result } +// currentDNS 读取网卡当前生效的 DNS,按地址族分开返回。 +// systemd-resolved 按网卡维护 DNS,此时 resolv.conf 内只有本机 stub 地址 +func currentDNS(ctx context.Context, name string) ([]string, []string) { + var servers []string + if output, err := run(ctx, "resolvectl", "dns", name); err == nil { + // 输出形如 Link 2 (ens5): 183.60.83.19 183.60.82.98 + if _, values, ok := strings.Cut(output, ":"); ok { + servers = strings.Fields(values) + } + } else if content, readErr := os.ReadFile("/etc/resolv.conf"); readErr == nil { + for line := range strings.SplitSeq(string(content), "\n") { + if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "nameserver" { + servers = append(servers, fields[1]) + } + } + } + + ipv4, ipv6 := make([]string, 0), make([]string, 0) + for _, server := range servers { + address, err := netip.ParseAddr(server) + // 本机 stub 解析器不是真实上游,回填无意义 + if err != nil || address.IsLoopback() { + continue + } + if address.Is4() { + ipv4 = append(ipv4, server) + } else { + ipv6 = append(ipv6, server) + } + } + return ipv4, ipv6 +} + +// defaultGateways 读取各网卡当前生效的默认网关 +func defaultGateways(ctx context.Context, ipv4 bool) map[string]string { + family := "-6" + if ipv4 { + family = "-4" + } + output, err := run(ctx, "ip", family, "route", "show", "default") + if err != nil { + return nil + } + + gateways := make(map[string]string) + for line := range strings.SplitSeq(output, "\n") { + fields := strings.Fields(line) + var gateway, device string + for i := 0; i+1 < len(fields); i++ { + switch fields[i] { + case "via": + gateway = fields[i+1] + case "dev": + device = fields[i+1] + } + } + // 同一网卡可能有多条默认路由,取指标最优的第一条 + if gateway != "" && device != "" && gateways[device] == "" { + gateways[device] = gateway + } + } + return gateways +} + func hasCommand(name string) bool { _, err := exec.LookPath(name) return err == nil diff --git a/web/src/api/panel/toolbox-network/index.ts b/web/src/api/panel/toolbox-network/index.ts index fa349ff43..1754781b2 100644 --- a/web/src/api/panel/toolbox-network/index.ts +++ b/web/src/api/panel/toolbox-network/index.ts @@ -26,14 +26,21 @@ export interface NetworkInterfaceConfig { ipv6: NetworkFamilyConfig } +// NetworkInterfaceState 网卡当前生效的状态,用于自动获取时回填表单 +export interface NetworkInterfaceState { + addresses: string[] + gateway: string + dns: string[] +} + export interface NetworkInterface extends Omit { type: string state: string mac: string current_mtu: number configured_mtu: number - current_ipv4: string[] - current_ipv6: string[] + current_ipv4: NetworkInterfaceState + current_ipv6: NetworkInterfaceState editable: boolean reason: string } diff --git a/web/src/views/toolbox/MigrationView.vue b/web/src/views/toolbox/MigrationView.vue index 03312817b..d5eb23964 100644 --- a/web/src/views/toolbox/MigrationView.vue +++ b/web/src/views/toolbox/MigrationView.vue @@ -15,7 +15,6 @@ import migration, { type MigrationResult } from '@/api/panel/toolbox-migration' import ws from '@/api/ws' -import TheIcon from '@/components/custom/TheIcon.vue' const { $gettext } = useGettext() @@ -49,24 +48,25 @@ let reconnectTimer: ReturnType | null = null const panels = computed(() => [ { value: 'acepanel' as MigrationPanel, - icon: 'solar:server-square-cloud-bold-duotone', title: $gettext('AcePanel → AcePanel'), description: $gettext('Push websites, databases, users and projects to another AcePanel.') }, { value: 'baota' as MigrationPanel, - icon: 'mdi:shield-crown-outline', title: $gettext('BaoTa → AcePanel'), description: $gettext('Pull websites, databases and projects from BT Panel.') }, { value: 'onepanel' as MigrationPanel, - icon: 'mdi:view-dashboard-variant-outline', title: $gettext('1Panel → AcePanel'), description: $gettext('Pull websites and databases from 1Panel.') } ]) +const currentDescription = computed( + () => panels.value.find((panel) => panel.value === connection.value.source_panel)?.description ?? '' +) + const isPush = computed(() => connection.value.source_panel === 'acepanel') const typeLabels = computed>(() => ({ @@ -455,24 +455,14 @@ watch( - - - - - - - {{ panel.title }} - {{ panel.description }} - - - - - + + + + {{ panel.title }} + + + {{ currentDescription }} + @@ -611,9 +601,3 @@ watch( - - diff --git a/web/src/views/toolbox/network/InterfaceView.vue b/web/src/views/toolbox/network/InterfaceView.vue index e15bf9def..fb0750512 100644 --- a/web/src/views/toolbox/network/InterfaceView.vue +++ b/web/src/views/toolbox/network/InterfaceView.vue @@ -7,6 +7,7 @@ import toolboxNetwork, { type NetworkFamilyConfig, type NetworkInterface, type NetworkInterfaceConfig, + type NetworkInterfaceState, type NetworkInterfaces, } from '@/api/panel/toolbox-network' @@ -177,14 +178,14 @@ const columns = computed>(() => [ title: $gettext('IPv4'), key: 'current_ipv4', minWidth: 210, - render: (row) => row.current_ipv4.join('\n') || '-', + render: (row) => row.current_ipv4.addresses.join('\n') || '-', className: 'network-addresses', }, { title: $gettext('IPv6'), key: 'current_ipv6', minWidth: 260, - render: (row) => row.current_ipv6.join('\n') || '-', + render: (row) => row.current_ipv6.addresses.join('\n') || '-', className: 'network-addresses', }, { @@ -245,20 +246,23 @@ const loadInterfaces = () => { }) } +// 自动获取时配置文件里没有这些值,回填当前生效状态,切换为手动时无需另行查询 +const fillFamily = ( + config: NetworkFamilyConfig, + current: NetworkInterfaceState +): NetworkFamilyConfig => ({ + ...config, + addresses: config.addresses.length ? [...config.addresses] : [...current.addresses], + gateway: config.gateway || current.gateway, + dns: config.dns.length ? [...config.dns] : [...current.dns] +}) + const openConfig = (item: NetworkInterface) => { editing.value = { name: item.name, - mtu: item.configured_mtu, - ipv4: { - ...item.ipv4, - addresses: [...item.ipv4.addresses], - dns: [...item.ipv4.dns], - }, - ipv6: { - ...item.ipv6, - addresses: [...item.ipv6.addresses], - dns: [...item.ipv6.dns], - }, + mtu: item.configured_mtu || item.current_mtu, + ipv4: fillFamily(item.ipv4, item.current_ipv4), + ipv6: fillFamily(item.ipv6, item.current_ipv6) } showModal.value = true } diff --git a/web/src/views/website/SettingView.vue b/web/src/views/website/SettingView.vue index 52c389dea..a8e56a10c 100644 --- a/web/src/views/website/SettingView.vue +++ b/web/src/views/website/SettingView.vue @@ -200,7 +200,7 @@ const handleSaveDefaultSite = () => { {{ $gettext( - 'When enabled, new websites will listen on IPv6 for all configured ports. Existing websites will only add IPv6 port 443 when HTTPS is enabled.', + 'When enabled, new websites will listen on IPv6 for all configured ports.', ) }}