From f09a4c50d1d062f56b29be4ba8ea42e851d29a81 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Thu, 29 Oct 2026 18:23:34 +0800 Subject: [PATCH] [feat] add configurable all-interface IPv4 listening, refs #129 --- README.md | 2 +- README_EN.md | 2 +- .../src/gui/application/gui_application.cpp | 54 +++++++++++++- .../assets/localization/localization_data.h | 4 + apps/desktop/src/gui/ui/crossdesk_ui.slint | 1 + apps/desktop/src/gui/ui/main_window.slint | 13 +++- apps/desktop/src/network/lan/lan_identity.cpp | 73 +++++++++++++------ apps/desktop/src/network/lan/lan_identity.h | 8 +- .../src/network/lan/lan_session_manager.cpp | 67 +++++++++++++++-- .../src/network/lan/lan_session_manager.h | 5 ++ .../desktop/src/network/lan/lan_transport.cpp | 33 ++++++++- docs/USER_GUIDE.md | 4 +- docs/USER_GUIDE_EN.md | 4 +- docs/connection-architecture.md | 14 ++-- localization/catalog.json | 32 +++++++- 15 files changed, 266 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 01a90fe3..95f1a0e9 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。 3. **输入密码。** 输入被控电脑在 IP 直连模式下显示的 **6 位英文字母或数字密码**,可勾选“记住密码”。切换模式时,“近期连接”同步切换为对应模式的设备记录。 -两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改各 IP 的端口和密码;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。 +两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改端口和密码,默认开启所有 IPv4 接口监听,支持通过 VPN 等接口连接,可在设置的“网络连接”中调整;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。 diff --git a/README_EN.md b/README_EN.md index 05083022..0aedb534 100644 --- a/README_EN.md +++ b/README_EN.md @@ -77,7 +77,7 @@ For self-hosting, both ends must use the same service. See the [user guide](docs 2. **Enter the address.** On the controller, enter the host's displayed `IP:port`, for example `192.168.1.20:52888`, and click **Connect**. Omitting the port uses **52888**; use the actual port shown on the host. 3. **Enter the password.** Use the host's **six-character ASCII letter/digit password** shown in Direct IP mode. You can select **Remember password**. Switching modes also switches the recent device list. -Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct). +Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. **Listen on all IPv4 interfaces** is enabled by default for VPN and other local interfaces on a shared port; change it under **Settings → General → Network**. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct). diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index d184ee99..7a0259b5 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1529,6 +1529,24 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) { main->set_codec_index(video_encode_format_button_value_); main->set_ip_direct_enabled_draft(config_center_->IsIpDirectEnabled()); main->set_settings_save_error(""); + try { + bool listen_all = true; + if (lan_) { + listen_all = lan_->identity().listen_all(); + } else { + const auto directory = path_manager_->GetConfigPath() / "lan"; + if (std::filesystem::exists(directory / "identity.json")) { + lan::Identity identity(directory); + identity.Load(); + listen_all = identity.listen_all(); + } + } + main->set_lan_listen_all(listen_all); + main->set_lan_listen_all_draft(listen_all); + } catch (const std::exception& error) { + main->set_settings_save_error( + main->global().invoke_error_message(UiText(error.what()))); + } main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_self_hosted_enabled(enable_self_hosted_); main->set_autostart_enabled(enable_autostart_); @@ -1767,7 +1785,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { try { lan_->identity().SelectAddress(std::string(address)); const auto port = - UiText(std::to_string(lan_->identity().PortFor(std::string(address)))); + UiText(std::to_string(lan_->PortFor(std::string(address)))); main->set_lan_listener_ip(address); main->set_lan_listener_port(port); main->set_lan_local_port(port); @@ -2452,6 +2470,7 @@ void GuiApplication::Tick() { TickLan(); if (LanAvailable()) { const auto address = lan_->identity().settings().value("address", ""); + ui_->main->set_lan_listen_all(lan_->identity().listen_all()); ui_->main->set_lan_password_pending(lan_->password_pending()); ui_->main->set_lan_listening(lan_->listening()); if (lan_connecting_ && lan_connecting_->authorized) { @@ -2504,7 +2523,7 @@ void GuiApplication::Tick() { ? available_interfaces.front().address : ""; } const auto port = - UiText(std::to_string(lan_->identity().PortFor(display_address))); + UiText(std::to_string(lan_->PortFor(display_address))); if (previous_address != display_address) { // A disappearing address must not transfer its unfinished port edit to // another interface. Explicit selection commits the old edit first. @@ -2523,7 +2542,8 @@ void GuiApplication::Tick() { if (!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) ui_->main->set_lan_local_port(port); ui_->main->set_lan_listener_error(UiText( - display_address.empty() ? "Selected LAN interface is unavailable" + display_address.empty() && !lan_->identity().listen_all() + ? "Selected LAN interface is unavailable" : lan_->ListenerError(display_address))); // Show failures one address at a time, without interrupting edits or another // dialog. A working listener must not clear another address's error. @@ -4570,6 +4590,8 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) { bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { const auto previous_server = GetSignalServerSettings(*config_center_); + const bool previous_ip_enabled = config_center_->IsIpDirectEnabled(); + const auto previous_connection_mode = config_center_->GetConnectionMode(); auto& main = ui_->Window(gallery_window); main->set_settings_save_error(""); const bool has_endpoint = main->get_self_hosted_enabled() || @@ -4630,6 +4652,32 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { localization::Literal("无法保存隐私屏解锁设置,请重试。", localization_language_index_))); return false; } + try { + const bool listen_all = main->get_lan_listen_all_draft(); + if (LanAvailable() && main->get_ip_direct_enabled_draft()) { + lan_->SetListenAll(listen_all); + } else if (lan_) { + // Persist the preference without rebinding a disabled/stopping listener. + if (listen_all != lan_->identity().listen_all()) + lan_->identity().SaveListenerMode(listen_all, {}); + } else if (listen_all != main->get_lan_listen_all()) { + // Do not open/create an inactive identity when this preference is unchanged. + lan::Identity identity(path_manager_->GetConfigPath() / "lan"); + identity.Load(); + if (listen_all != identity.listen_all()) + identity.SaveListenerMode(listen_all, {}); + } + } catch (const std::exception& error) { + if (config_center_->SetIpDirectEnabled(previous_ip_enabled) != 0 || + config_center_->SetConnectionMode(previous_connection_mode) != 0) + LOG_ERROR("Failed to restore IP availability after listener settings failure"); + if (wall_changed && !ui_->gallery.Save(ui_->gallery_path)) + LOG_ERROR("Failed to restore DeskHub settings after listener settings failure"); + main->set_settings_tab(0); + main->set_settings_save_error( + main->global().invoke_error_message(UiText(error.what()))); + return false; + } language_button_value_ = std::clamp(main->get_language_index(), 0, localization::kSystemLanguage); video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1); diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 172e77d4..77056ec5 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -492,6 +492,8 @@ inline constexpr TranslationRow kTranslationRows[] = { {"高", {"高", "High", "Высокое", "高", "高", "높음", "Hoch", "Élevée", "Alta", "Alta"}}, {"(仅在交互终端显示)", {"(仅在交互终端显示)", "(shown only in an interactive terminal)", "(виден только в интерактивном терминале)", "(僅在互動終端顯示)", "(対話型端末でのみ表示)", "(대화형 터미널에만 표시됨)", "(wird nur in einem interaktiven Terminal angezeigt)", "(affiché uniquement dans un terminal interactif)", "(se muestra solo en una terminal interactiva)", "(mostrado apenas em um terminal interativo)"}}, {":同步键鼠到所有已连接设备", {":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах", ":同步鍵鼠到所有已連線裝置", ": 接続されているすべてのデバイスへのミラー入力", ": 연결된 모든 장치에 대한 미러 입력", ": Eingang auf alle angeschlossenen Geräte spiegeln", ": entrée miroir sur tous les appareils connectés", ": entrada espejo para todos los dispositivos conectados", ": entrada espelhada para todos os dispositivos conectados"}}, + {"监听所有 IPv4 接口", {"监听所有 IPv4 接口", "Listen on all IPv4 interfaces", "Все интерфейсы IPv4", "監聽所有 IPv4 介面", "すべての IPv4 で待ち受け", "모든 IPv4 인터페이스 수신", "Alle IPv4-Schnittstellen", "Toutes les interfaces IPv4", "Todas las interfaces IPv4", "Todas as interfaces IPv4"}}, + {"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", {"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", "Use one port on all interfaces, including VPNs. Select an actual IP to share. Turn off to restore separate ports per IP.", "Один порт для всех интерфейсов, включая VPN. Для подключения выберите реальный IP. Отключение вернёт отдельные порты для каждого IP.", "所有介面共用一個連接埠,包括 VPN。分享時請選擇實際 IP;關閉後恢復各 IP 的獨立連接埠。", "VPN を含む全インターフェースで同じポートを使います。共有には実際の IP を選択してください。オフにすると IP ごとのポートに戻ります。", "VPN을 포함한 모든 인터페이스가 하나의 포트를 사용합니다. 공유할 실제 IP를 선택하세요. 끄면 IP별 포트가 복원됩니다.", "Ein Port für alle Schnittstellen, einschließlich VPN. Zum Teilen eine tatsächliche IP wählen. Ausschalten stellt die Ports pro IP wieder her.", "Un port commun à toutes les interfaces, VPN inclus. Choisissez une IP réelle à partager. Désactivez pour rétablir les ports propres à chaque IP.", "Un puerto para todas las interfaces, incluidas las VPN. Seleccione una IP real para compartir. Desactive para restaurar los puertos de cada IP.", "Uma porta para todas as interfaces, incluindo VPNs. Selecione um IP real para compartilhar. Desative para restaurar as portas de cada IP."}}, }; struct NamedTranslation { const char* name; const char* literal; }; @@ -986,6 +988,8 @@ inline constexpr const char* kSlintLiterals[] = { "未连接", "确认断开", "确认执行", + "监听所有 IPv4 接口", + "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", }; } // namespace detail diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index 1f5401a9..2302dcb5 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -9,5 +9,6 @@ export { FileTransferNotification } from "file_transfer_notification.slint"; export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint"; export { GalleryStrings } from "device_strings.slint"; +export { LanStrings } from "lan_strings.slint"; export { GalleryNetworkDevice } from "gallery_network_dialog.slint"; export { DeviceGroupOption } from "device_group_dialog.slint"; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 5dffc641..d8286361 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -465,6 +465,7 @@ export component MainWindow inherits Window { } } in-out property lan-listening; + in-out property lan-listen-all: true; in-out property lan-listener-ip; in-out property lan-listener-port: "52888"; out property lan-listener-address: root.lan-listener-ip == "" ? "" : root.lan-listener-ip + ":" + root.lan-listener-port; @@ -634,6 +635,7 @@ export component MainWindow inherits Window { in-out property capture-method-index: 0; in-out property codec-index: 0; in-out property ip-direct-enabled-draft: true; + in-out property lan-listen-all-draft: true; in property settings-save-error; in-out property force-relay-enabled: false; in-out property self-hosted-enabled: false; @@ -1282,7 +1284,7 @@ export component MainWindow inherits Window { width: lan-edit-port-metrics.preferred-width + 20px; height: 22px; quiet: true; text: lan-edit-port-metrics.text; text-size: 12px; text-color: WorkbenchStyle.accent; - enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0; + enabled: root.lan-mode && !root.lan-password-pending && (root.lan-listen-all || root.lan-interfaces.length > 0); clicked => { // Moving from the editor to this action is still editing. root.lan-apply-pending = false; @@ -2720,11 +2722,16 @@ export component MainWindow inherits Window { } } SettingsGroup { - title: GalleryStrings.texts[116]; rows: 3; + title: GalleryStrings.texts[116]; rows: 4; SettingsToggleRow { label: GalleryStrings.texts[117]; checked <=> root.ip-direct-enabled-draft; } + SettingsToggleRow { + label: GalleryStrings.texts[206]; + checked <=> root.lan-listen-all-draft; + enabled: root.ip-direct-enabled-draft; + } SettingsToggleRow { label: UiStrings.force-relay; checked <=> root.force-relay-enabled; enabled: !root.settings-session-active; } SettingsToggleRow { label: UiStrings.self-hosted; @@ -2744,7 +2751,7 @@ export component MainWindow inherits Window { } } Text { - text: GalleryStrings.texts[119]; + text: GalleryStrings.texts[119] + "\n" + GalleryStrings.texts[207]; font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap; } SettingsGroup { diff --git a/apps/desktop/src/network/lan/lan_identity.cpp b/apps/desktop/src/network/lan/lan_identity.cpp index f0b1f9c1..d86ce328 100644 --- a/apps/desktop/src/network/lan/lan_identity.cpp +++ b/apps/desktop/src/network/lan/lan_identity.cpp @@ -405,7 +405,7 @@ std::pair ParseEndpoint(const std::string& value, "Enter a numeric IPv4 address or IPv4:port"); return {address, port}; } -std::vector PhysicalInterfaces() { +std::vector LocalIpv4Interfaces(bool physical_only) { std::vector result; #ifdef _WIN32 ULONG bytes = 16384; @@ -424,17 +424,22 @@ std::vector PhysicalInterfaces() { if (code != NO_ERROR) return result; for (auto* a = adapters; a; a = a->Next) { if (a->OperStatus != IfOperStatusUp || - (a->IfType != IF_TYPE_ETHERNET_CSMACD && - a->IfType != IF_TYPE_IEEE80211) || - a->PhysicalAddressLength == 0) - continue; - // HardwareInterface rejects software adapters that imitate Ethernet. - MIB_IF_ROW2 row{}; - row.InterfaceLuid = a->Luid; - if (GetIfEntry2(&row) != NO_ERROR || - !row.InterfaceAndOperStatusFlags.HardwareInterface) + a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue; + if (physical_only) { + if ((a->IfType != IF_TYPE_ETHERNET_CSMACD && + a->IfType != IF_TYPE_IEEE80211) || + a->PhysicalAddressLength == 0) + continue; + MIB_IF_ROW2 row{}; + row.InterfaceLuid = a->Luid; + if (GetIfEntry2(&row) != NO_ERROR || + !row.InterfaceAndOperStatusFlags.HardwareInterface) + continue; + } for (auto* u = a->FirstUnicastAddress; u; u = u->Next) { + if (!u->Address.lpSockaddr || u->Address.lpSockaddr->sa_family != AF_INET) + continue; char address[INET_ADDRSTRLEN]{}; inet_ntop( AF_INET, @@ -448,8 +453,8 @@ std::vector PhysicalInterfaces() { if (getifaddrs(&list) != 0) return result; for (auto* a = list; a; a = a->ifa_next) { if (!a->ifa_addr || a->ifa_addr->sa_family != AF_INET || - !(a->ifa_flags & IFF_UP) || - (a->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT))) + !(a->ifa_flags & IFF_UP) || (a->ifa_flags & IFF_LOOPBACK) || + (physical_only && (a->ifa_flags & IFF_POINTOPOINT))) continue; bool physical = false; #ifdef __APPLE__ @@ -464,7 +469,7 @@ std::vector PhysicalInterfaces() { physical = std::filesystem::exists(std::filesystem::path("/sys/class/net") / a->ifa_name / "device"); #endif - if (!physical) continue; + if (physical_only && !physical) continue; char address[INET_ADDRSTRLEN]{}; inet_ntop(AF_INET, &reinterpret_cast(a->ifa_addr)->sin_addr, address, sizeof(address)); @@ -472,6 +477,13 @@ std::vector PhysicalInterfaces() { } freeifaddrs(list); #endif + std::sort(result.begin(), result.end(), + [](const auto& a, const auto& b) { return a.address < b.address; }); + result.erase(std::unique(result.begin(), result.end(), + [](const auto& a, const auto& b) { + return a.address == b.address; + }), + result.end()); return result; } std::string RandomHex(size_t bytes) { @@ -533,6 +545,9 @@ void Identity::Load() { auto& settings = state_["settings"]; if (settings.is_null()) settings = nlohmann::json::object(); Require(settings.is_object(), "Invalid LAN settings"); + Require( + !settings.contains("listen_all") || settings["listen_all"].is_boolean(), + "Invalid LAN listener mode"); const auto address = settings.value("address", std::string{}); Require(address.empty() || IsIpv4(address), "Invalid LAN listener address"); if (!settings.contains("ports")) { @@ -544,7 +559,8 @@ void Identity::Load() { } Require(settings["ports"].is_object(), "Invalid LAN port settings"); for (const auto& [ip, port] : settings["ports"].items()) - Require(IsIpv4(ip) && port.is_number_integer() && port > 0 && port <= 65535, + Require((IsIpv4(ip) || ip == kAllIpv4Interfaces) && + port.is_number_integer() && port > 0 && port <= 65535, "Invalid LAN port settings"); settings["address"] = address; settings["version"] = 2; @@ -652,14 +668,16 @@ bool Identity::VerifyPassword(const nlohmann::json& verifier, } void Identity::SaveSettings(const std::string& address, unsigned port) { - Require(IsIpv4(address) && port > 0 && port <= 65535, + Require((IsIpv4(address) || address == kAllIpv4Interfaces) && port > 0 && + port <= 65535, "Invalid LAN listener address"); auto next = state_; - const auto interfaces = PhysicalInterfaces(); - const auto selected = std::find_if( - interfaces.begin(), interfaces.end(), - [&](const auto& i) { return i.address == address; }); - Require(selected != interfaces.end(), "Selected LAN interface is unavailable"); + const auto interfaces = LocalIpv4Interfaces(); + const auto selected = + std::find_if(interfaces.begin(), interfaces.end(), + [&](const auto& i) { return i.address == address; }); + Require(address == kAllIpv4Interfaces || selected != interfaces.end(), + "Selected LAN interface is unavailable"); next["settings"]["ports"][address] = port; Save(next); state_ = std::move(next); @@ -671,7 +689,7 @@ bool Identity::HasPortFor(const std::string& address) const { return state_.at("settings").at("ports").contains(address); } void Identity::SelectAddress(const std::string& address) { - const auto interfaces = PhysicalInterfaces(); + const auto interfaces = LocalIpv4Interfaces(!listen_all()); Require(std::any_of(interfaces.begin(), interfaces.end(), [&](const auto& i) { return i.address == address; }), "Selected LAN interface is unavailable"); @@ -681,4 +699,17 @@ void Identity::SelectAddress(const std::string& address) { Save(next); state_ = std::move(next); } +void Identity::SaveListenerMode(bool listen_all, + const std::map& ports) { + auto next = state_; + next["settings"]["listen_all"] = listen_all; + for (const auto& [address, port] : ports) { + Require((IsIpv4(address) || address == kAllIpv4Interfaces) && port > 0 && + port <= 65535, + "Invalid LAN listener address"); + next["settings"]["ports"][address] = port; + } + Save(next); + state_ = std::move(next); +} } // namespace crossdesk::lan diff --git a/apps/desktop/src/network/lan/lan_identity.h b/apps/desktop/src/network/lan/lan_identity.h index 1514185f..043ca0ad 100644 --- a/apps/desktop/src/network/lan/lan_identity.h +++ b/apps/desktop/src/network/lan/lan_identity.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -28,7 +29,9 @@ unsigned ParsePort(const std::string& value); std::pair ParseEndpoint(const std::string& value, unsigned default_port); bool IsIpv4(const std::string& value); -std::vector PhysicalInterfaces(); +inline constexpr char kAllIpv4Interfaces[] = "0.0.0.0"; +// Non-loopback unicast IPv4 addresses, optionally restricted to physical NICs. +std::vector LocalIpv4Interfaces(bool physical_only = false); std::string RandomHex(size_t bytes); bool ValidPassword(const std::string& password); bool VerifyLanCertificate(X509* cert); @@ -51,6 +54,9 @@ class Identity { // their saved port. Selection only controls the address shown in the UI. unsigned PortFor(const std::string& address) const; bool HasPortFor(const std::string& address) const; + bool listen_all() const { return settings().value("listen_all", true); } + void SaveListenerMode(bool listen_all, + const std::map& ports); void SelectAddress(const std::string& address); const std::string& id() const { return id_; } std::filesystem::path certificate() const { diff --git a/apps/desktop/src/network/lan/lan_session_manager.cpp b/apps/desktop/src/network/lan/lan_session_manager.cpp index 1decd377..90fdb376 100644 --- a/apps/desktop/src/network/lan/lan_session_manager.cpp +++ b/apps/desktop/src/network/lan/lan_session_manager.cpp @@ -100,7 +100,8 @@ SessionManager::SessionManager(std::filesystem::path path, Hooks hooks) // Migrate the saved password, retaining its value, identity and settings. SetPassword(access_password_); } else if (identity_.verifier().is_null()) { - // Generate the initial password before automatically accepting connections. + // Generate the initial password before automatically accepting + // connections. ResetPassword(); } else { hooks_.error("LAN password is not ready"); @@ -122,7 +123,49 @@ void SessionManager::Configure(const std::string& address, unsigned port) { if (!port || port > 65535) throw std::runtime_error("Invalid LAN port"); if (!Identity::CurrentVerifier(identity_.verifier())) throw std::runtime_error("LAN password is not ready"); - ReplaceListener(address, port, true); + ReplaceListener(identity_.listen_all() ? kAllIpv4Interfaces : address, port, + true); +} +void SessionManager::SetListenAll(bool enabled) { + if (stopping_ || enabled == identity_.listen_all()) return; + if (!Identity::CurrentVerifier(identity_.verifier())) + throw std::runtime_error("LAN password is not ready"); + const auto interfaces = LocalIpv4Interfaces(!enabled); + const auto previous = listeners_; + // Wildcard and per-address listeners can overlap on the same port. Retire + // acceptors before rebinding; authenticated channels retain their sockets. + for (const auto& [_, listener] : previous) RetireListener(listener.id); + listeners_.clear(); + std::map ports; + try { + const auto add = [&](const std::string& address) { + ReplaceListener(address, identity_.PortFor(address), false, + !identity_.HasPortFor(address)); + ports[address] = listeners_.at(address).port; + }; + if (enabled) + add(kAllIpv4Interfaces); + else + for (const auto& interface : interfaces) add(interface.address); + // Publish the mode and all newly allocated ports in one durable write. + identity_.SaveListenerMode(enabled, ports); + } catch (...) { + for (const auto& [_, listener] : listeners_) RetireListener(listener.id); + listeners_.clear(); + // Keep the original configuration and restore its exact ports. A failed + // restore remains visible and is retried by ordinary listener maintenance. + for (const auto& [address, listener] : previous) { + try { + ReplaceListener(address, listener.port, false); + } catch (const std::exception& error) { + SetListenerError(address, listener.port, error.what()); + } + } + throw; + } + interfaces_ = interfaces; + listener_errors_.clear(); + listener_warnings_.clear(); } void SessionManager::ReplaceListener(const std::string& address, unsigned port, bool save, bool allow_fallback) { @@ -220,7 +263,8 @@ void SessionManager::SetListenerError(const std::string& address, unsigned port, listener_warnings_[address] = std::move(issue); } std::string SessionManager::ListenerError(const std::string& address) const { - const auto it = listener_errors_.find(address); + const auto it = listener_errors_.find( + identity_.listen_all() ? kAllIpv4Interfaces : address); return it == listener_errors_.end() ? "" : it->second.error; } std::optional SessionManager::TakeListenerWarning() { @@ -233,6 +277,7 @@ std::optional SessionManager::TakeListenerWarning void SessionManager::RefreshListeners() { if (stopping_ || !Identity::CurrentVerifier(identity_.verifier())) return; const auto available = [&](const auto& address) { + if (identity_.listen_all()) return address == kAllIpv4Interfaces; return std::any_of(interfaces_.begin(), interfaces_.end(), [&](const auto& i) { return i.address == address; }); }; @@ -252,7 +297,10 @@ void SessionManager::RefreshListeners() { listener_warnings_.erase(it->first); it = listener_errors_.erase(it); } - for (const auto& interface : interfaces_) { + const auto desired = identity_.listen_all() + ? std::vector{{kAllIpv4Interfaces}} + : interfaces_; + for (const auto& interface : desired) { const auto port = identity_.PortFor(interface.address); const bool initialize = !identity_.HasPortFor(interface.address); try { @@ -672,7 +720,10 @@ void SessionManager::Tick() { for (auto& event : transport_.Poll()) { auto it = sessions_.find(event.channel->id); if (it == sessions_.end()) { - const auto listener = listeners_.find(event.channel->local_address); + const auto listener = std::find_if( + listeners_.begin(), listeners_.end(), [&](const auto& entry) { + return entry.second.id == event.channel->listener; + }); if (event.kind != Transport::Event::Open || !event.channel->incoming || stopping_ || listener == listeners_.end() || event.channel->listener != listener->second.id) { @@ -763,12 +814,14 @@ void SessionManager::Tick() { } if (!stopping_ && now - interface_check_ >= std::chrono::seconds(2)) { interface_check_ = now; - interfaces_ = PhysicalInterfaces(); + const auto local_interfaces = LocalIpv4Interfaces(); + interfaces_ = + identity_.listen_all() ? local_interfaces : LocalIpv4Interfaces(true); RefreshListeners(); for (auto& [_, s] : sessions_) if (s->phase != Session::Connecting && !s->channel->local_address.empty() && - std::none_of(interfaces_.begin(), interfaces_.end(), + std::none_of(local_interfaces.begin(), local_interfaces.end(), [&](const auto& i) { return i.address == s->channel->local_address; })) diff --git a/apps/desktop/src/network/lan/lan_session_manager.h b/apps/desktop/src/network/lan/lan_session_manager.h index ddea4116..d72a7953 100644 --- a/apps/desktop/src/network/lan/lan_session_manager.h +++ b/apps/desktop/src/network/lan/lan_session_manager.h @@ -79,6 +79,11 @@ class SessionManager { Identity& identity() { return identity_; } // Refreshed by Tick; UI and listener maintenance share the same snapshot. const std::vector& interfaces() const { return interfaces_; } + void SetListenAll(bool enabled); + unsigned PortFor(const std::string& address) const { + return identity_.PortFor(identity_.listen_all() ? kAllIpv4Interfaces + : address); + } void Configure(const std::string& address, unsigned port); bool password_pending() const { return reset_job_.valid(); } void ResetPassword(); diff --git a/apps/desktop/src/network/lan/lan_transport.cpp b/apps/desktop/src/network/lan/lan_transport.cpp index f29c6081..fe34de4c 100644 --- a/apps/desktop/src/network/lan/lan_transport.cpp +++ b/apps/desktop/src/network/lan/lan_transport.cpp @@ -25,7 +25,8 @@ constexpr const char* kPath = "/crossdesk/lan/v2"; constexpr const char* kProtocol = "crossdesk.lan.v2"; void ValidateListener(const std::string& address, unsigned port) { if (port > 65535) throw std::runtime_error("Invalid LAN port"); - const auto interfaces = PhysicalInterfaces(); + if (address == kAllIpv4Interfaces) return; + const auto interfaces = LocalIpv4Interfaces(); if (!IsIpv4(address) || std::none_of(interfaces.begin(), interfaces.end(), [&](const auto& i) { return i.address == address; })) @@ -57,7 +58,7 @@ void CheckListenError(const Error& error) { throw std::runtime_error("LanPortPermissionDenied"); throw std::runtime_error("Cannot listen on LAN port: " + error.message()); } -void CheckExistingListener(const std::string& address, unsigned port) { +void CheckExistingEndpoint(const std::string& address, unsigned port) { #ifdef __APPLE__ // BSD permits a specific-address listener alongside a wildcard listener with // SO_REUSEADDR. Detect that live listener without treating TIME_WAIT or our @@ -88,6 +89,32 @@ void CheckExistingListener(const std::string& address, unsigned port) { (void)port; #endif } +void CheckExistingListener(const std::string& address, unsigned port) { +#ifdef __APPLE__ + if (address == kAllIpv4Interfaces) { + // BSD permits wildcard/specific overlap with SO_REUSEADDR. Probe binds on + // each exact address to detect specific listeners without sending traffic + // through VPN adapters which may accept connects to otherwise unused ports. + // The final wildcard bind detects an existing wildcard listener itself. + asio::io_context io; + auto addresses = LocalIpv4Interfaces(); + addresses.push_back({"127.0.0.1"}); + for (const auto& interface : addresses) { + asio::ip::tcp::acceptor probe(io); + Error error; + probe.open(asio::ip::tcp::v4(), error); + CheckListenError(error); + probe.set_option(asio::socket_base::reuse_address(true), error); + CheckListenError(error); + probe.bind({asio::ip::make_address_v4(interface.address), + static_cast(port)}, error); + CheckListenError(error); + } + return; + } +#endif + CheckExistingEndpoint(address, port); +} std::shared_ptr TlsContext() { auto context = std::make_shared(Context::tls); if (SSL_CTX_set_min_proto_version(context->native_handle(), TLS1_3_VERSION) != @@ -265,7 +292,7 @@ struct Transport::Impl { } c->local_address = local.address().to_string(); c->remote_address = remote.address().to_string(); - const auto interfaces = PhysicalInterfaces(); + const auto interfaces = LocalIpv4Interfaces(); if (!IsIpv4(c->remote_address) || std::none_of( interfaces.begin(), interfaces.end(), diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 942fa384..b6751891 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -63,7 +63,9 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-.deb" IP 直连密码弹窗与记住密码选项,背景为演示局域网设备

-**地址和端口:** 点击“切换 IP”选择展示与分享的地址,点击“修改端口”编辑该 IP 的端口。程序同时监听可用物理网卡上的 IPv4,每个 IP 单独保存端口;切换展示地址不会停用其他地址。首次为某个 IP 分配端口时优先使用 **52888**,被占用时自动分配并保存可用端口。已有配置的端口发生冲突时会提示,需手动修改。端口范围为 **1–65535**,修改失败会保留原设置;连接方需要使用界面显示的实际端口。局域网防火墙需允许 CrossDesk 的 TCP 协商连接和 UDP 远控流量。 +**地址和端口:** 点击“切换 IP”选择展示与分享的地址,点击“修改端口”编辑该 IP 的端口。默认所有接口共用一个端口;关闭“监听所有 IPv4 接口”后,程序同时监听可用物理网卡上的 IPv4,每个 IP 单独保存端口。切换展示地址不会停用其他地址。首次为某个 IP 分配端口时优先使用 **52888**,被占用时自动分配并保存可用端口。已有配置的端口发生冲突时会提示,需手动修改。端口范围为 **1–65535**,修改失败会保留原设置;连接方需要使用界面显示的实际端口。局域网防火墙需允许 CrossDesk 的 TCP 协商连接和 UDP 远控流量。 + +**所有接口监听:** 在 **设置 → 常规 → 网络连接** 中开启“监听所有 IPv4 接口”并保存,即可让有线、Wi-Fi、VPN 和虚拟网卡共用一个 TCP 协商端口。此选项默认开启,已有的显式关闭设置会保留;修改后点击“保存”生效,点击“取消”不改变监听,重启仍沿用已保存设置;关闭后恢复原来各物理网卡 IP 的端口。该模式下修改任一展示地址的端口,会修改所有接口共用的端口。“切换 IP”和复制仍使用本机实际地址,不能把 `0.0.0.0` 作为远端地址。新接口自动使用共同端口;VPN 两端仍须允许 TCP 和 UDP 互通。不支持 IPv6、回环远控或自动公网 NAT 穿透。切换监听范围会取消未完成认证的连接;已认证连接保留。若端口被占用或设置无法保存,切换失败并尝试恢复原监听,恢复失败的端口会显示错误并持续重试。 **密码:** IP 直连使用独立的 **6 位英文字母或数字密码,区分大小写**。没有设置时自动生成,眼睛、刷新和铅笔按钮分别用于显示 / 隐藏、随机生成和自定义;修改成功后即时生效,无需重启或连接服务器。已保存旧密码的控制端需要重新输入。 diff --git a/docs/USER_GUIDE_EN.md b/docs/USER_GUIDE_EN.md index 1d3f63ff..90f9acf9 100644 --- a/docs/USER_GUIDE_EN.md +++ b/docs/USER_GUIDE_EN.md @@ -63,7 +63,9 @@ The direct IP entry is available in the regular Windows, macOS, and Linux deskto Direct IP password dialog with Remember password and demonstration LAN devices in the background

-**Addresses and ports:** use **Switch IP** to choose the displayed address and **Edit port** to change its port. The app listens on available physical-interface IPv4 addresses simultaneously and saves a separate port for each IP. Selecting a displayed address keeps the others listening. The first port assignment for an IP tries **52888**; if busy, it allocates and saves an available port. A conflict on an already saved port is reported and requires a manual change. Valid ports are **1–65535**; failed changes preserve the previous settings. Connect using the actual port displayed on the host. The LAN firewall must allow CrossDesk's TCP negotiation connection and UDP remote-control traffic. +**Addresses and ports:** use **Switch IP** to choose the displayed address and **Edit port** to change its port. By default all interfaces share one port. When **Listen on all IPv4 interfaces** is disabled, the app listens on available physical-interface IPv4 addresses simultaneously and saves a separate port for each IP. Selecting a displayed address keeps the others listening. The first port assignment for an IP tries **52888**; if busy, it allocates and saves an available port. A conflict on an already saved port is reported and requires a manual change. Valid ports are **1–65535**; failed changes preserve the previous settings. Connect using the actual port displayed on the host. The LAN firewall must allow CrossDesk's TCP negotiation connection and UDP remote-control traffic. + +**Listen on all interfaces:** enable **Listen on all IPv4 interfaces** under **Settings → General → Network** and save to share one TCP negotiation port across Ethernet, Wi-Fi, VPN, and virtual interfaces. This option is on by default; an explicitly saved off setting is preserved. Changes apply on Save and persist across restarts; Cancel leaves the listening scope unchanged. Turning it off restores the saved ports for individual physical-interface IPs. Editing the port of any displayed address in this mode changes the shared port. **Switch IP** and Copy still use actual local addresses; `0.0.0.0` is not a remote connection address. New interfaces use the shared port automatically. Both VPN endpoints must allow TCP and UDP traffic. IPv6, loopback remote control, and automatic public NAT traversal remain unsupported. Switching the listening scope cancels unfinished authentication but preserves authenticated connections. If binding or persistence fails, the app attempts to restore the previous listeners; any failed restoration is reported and retried. **Passwords:** direct IP has its own **six-character, case-sensitive ASCII letter/digit password**. A missing password is generated automatically. Use the eye, refresh, and pencil buttons to show/hide, randomly generate, or customize it. A successful change applies immediately without restarting or connecting to a server. Controllers that saved the old password must enter the new one. diff --git a/docs/connection-architecture.md b/docs/connection-architecture.md index 53efad0c..0c9ffc09 100644 --- a/docs/connection-architecture.md +++ b/docs/connection-architecture.md @@ -112,15 +112,17 @@ LAN 协商通道使用 TLS 1.3、路径 `/crossdesk/lan/v2` 和 WebSocket 子协 `GuiRuntime::InitializeLan()` 在功能启用且运行宿主支持时创建 `lan::SessionManager`。管理器加载本地 LAN 身份、证书、密码和端口配置;尚无密码时先异步生成六位英文字母或数字密码,准备完成后开始接受连接。 -- `PhysicalInterfaces()` 枚举符合平台筛选条件的物理网卡 IPv4,排除回环、点对点及不支持的虚拟接口。当前只接收数字 IPv4 地址,不解析主机名或 IPv6。 -- 管理器约每 2 秒刷新接口快照;界面和监听维护共用该快照。可用地址分别监听,不绑定到一个通配地址来代替所有 IP。 +- `LocalIpv4Interfaces()` 枚举已启用接口上的非回环单播 IPv4,包含 VPN 和虚拟接口;关闭所有接口监听后的独立监听模式使用 `physical_only` 筛选物理网卡。当前只接收数字 IPv4 地址,不解析主机名或 IPv6。 +- 管理器约每 2 秒刷新接口快照。默认使用单个 `0.0.0.0` 监听,界面可选择包括 VPN 在内的实际 IPv4 分享;在“设置 → 常规 → 网络连接”中关闭“监听所有 IPv4 接口”并保存后,改为按物理网卡地址分别监听,界面也只列出这些地址。通配地址只用于绑定,不作为连接目标或 ICE 候选。 - 每个 IP 首次配置优先尝试 `52888`;无法绑定时请求操作系统分配端口,并在保持 socket 占用期间保存实际端口,避免“探测后释放、使用时被抢占”的竞争。 - 已保存的端口冲突时报告错误并重试原端口,不自动换号。手动改端口按“绑定新端口 → 保存配置 → 替换监听”的顺序执行,失败时保留原设置。 - “切换 IP”只改变显示和分享的地址。端口修改会停止旧端口接受新连接,但保留已经完成认证的会话及其他 IP 的监听。 - 网卡下线或 DHCP 换址导致本地 IP 从接口快照中消失时,管理器停止该地址的监听,并以 `InterfaceRemoved` 关闭使用该本地 IP 的已有会话。其他 IP 的监听继续运行;当前直连协议只允许一轮 ICE 协商,不支持会话内 ICE 重启或无缝迁移到另一张网卡,需要重新建立连接。 -- 端口配置以 **IP 地址**为键,不跟随网卡标识。DHCP 分配未记录的新 IP 时,该地址重新执行“尝试 `52888` → 必要时分配可用端口”的首次配置流程;旧 IP 再次出现时沿用其保存的端口。 +- 端口配置以 **IP 地址**为键,不跟随网卡标识。所有接口模式的端口单独保存在 `ports["0.0.0.0"]`,`listen_all` 保存模式,缺省为 `true`,已保存的 `false` 保留;设置页维护草稿,保存后应用,取消不改监听。切换不会覆盖各实际 IP 的端口。独立监听模式下,DHCP 分配未记录的新 IP 时,该地址重新执行“尝试 `52888` → 必要时分配可用端口”的首次配置流程;旧 IP 再次出现时沿用其保存的端口。 -这里的 LAN 策略限定接口与候选地址,不通过“是否有外网”判断工作模式,也不只是检查地址是否属于某个私网网段。实际连接仍要求本次 WSS 连接使用的双方 IPv4 的 TCP 和 UDP 路径可达。 +切换监听范围时先停止旧 acceptor,绑定目标监听并原子保存模式与新端口;失败时保留原设置并尝试重新绑定旧端口,恢复失败由后台维护继续重试并显示错误。已认证通道保留,未完成认证的通道关闭。所有接口模式在网卡增减时保持同一个监听,已有会话仍按其实际本地地址的存续状态处理。 + +这里的 LAN 策略限定候选地址,不通过“是否有外网”判断工作模式,也不只是检查地址是否属于某个私网网段。实际连接仍要求本次 WSS 连接使用的双方 IPv4 的 TCP 和 UDP 路径可达。 ### 3.3 认证与协商时序 @@ -260,14 +262,14 @@ LAN 近期记录的设备 ID 用于归档,不用于确认对端身份。重连 ## 7. 支持范围与排查入口 -IP 直连目前面向 Windows、macOS、Linux 的普通桌面客户端。浏览器、原生移动端、Windows 登录前服务和 Linux 无头控制台没有这条直连入口。物理网卡筛选、数字 IPv4 和 UDP host 候选是当前实现约束,不能把它当作主机名解析、IPv6、任意 VPN 或公网 NAT 穿透的通用入口。 +IP 直连目前面向 Windows、macOS、Linux 的普通桌面客户端。浏览器、原生移动端、Windows 登录前服务和 Linux 无头控制台没有这条直连入口。数字 IPv4 和 UDP host 候选是当前实现约束。所有接口模式可接受 VPN 或虚拟网卡上的连接,控制端也允许通过这些接口发起连接,但仍要求双方 TCP、UDP 路径可达;不提供主机名解析、IPv6、回环远控或公网 NAT 穿透。 | 现象 | 首先检查的层 | | --- | --- | | ID 模式未连接服务器 | WSS 服务地址、TLS 信任、登录与重连状态 | | ID 可查询但远控失败 | Join 返回的凭据/限流错误,以及 ICE 配置、直连或 TURN 路径 | | IP 模式按钮未显示 | `enable_ip_direct` 设置是否开启并保存 | -| IP / 端口无法连接 | 对方实际显示的端口、物理接口、TCP 监听及防火墙 | +| IP / 端口无法连接 | 对方实际显示的地址和端口、监听范围、TCP 监听及防火墙 | | 密码通过但没有远控画面 | 双方 UDP 可达性、HostOnly 候选检查、DTLS/SRTP 就绪状态及采集权限 | | 改端口后旧连接仍能使用 | 已认证会话保留原通道,这是端口热更新的预期行为 | | 网卡下线或换 IP 后直连中断 | 原本地 IP 是否消失;关联会话会关闭,需使用当前可达的 IP/端口重新连接 | diff --git a/localization/catalog.json b/localization/catalog.json index 3e58bd39..0e2628cf 100644 --- a/localization/catalog.json +++ b/localization/catalog.json @@ -10259,6 +10259,30 @@ "fr": "Session distante active. La connexion reste disponible pour confirmer ou annuler l’arrêt.", "es": "Sesión remota activa. Se mantiene la conexión para confirmar o cancelar el apagado.", "pt-BR": "Sessão remota ativa. A conexão é mantida para confirmar ou cancelar o desligamento." + }, + "监听所有 IPv4 接口": { + "zh-Hans": "监听所有 IPv4 接口", + "en": "Listen on all IPv4 interfaces", + "ru": "Все интерфейсы IPv4", + "zh-Hant": "監聽所有 IPv4 介面", + "ja": "すべての IPv4 で待ち受け", + "ko": "모든 IPv4 인터페이스 수신", + "de": "Alle IPv4-Schnittstellen", + "fr": "Toutes les interfaces IPv4", + "es": "Todas las interfaces IPv4", + "pt-BR": "Todas as interfaces IPv4" + }, + "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。": { + "zh-Hans": "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", + "en": "Use one port on all interfaces, including VPNs. Select an actual IP to share. Turn off to restore separate ports per IP.", + "ru": "Один порт для всех интерфейсов, включая VPN. Для подключения выберите реальный IP. Отключение вернёт отдельные порты для каждого IP.", + "zh-Hant": "所有介面共用一個連接埠,包括 VPN。分享時請選擇實際 IP;關閉後恢復各 IP 的獨立連接埠。", + "ja": "VPN を含む全インターフェースで同じポートを使います。共有には実際の IP を選択してください。オフにすると IP ごとのポートに戻ります。", + "ko": "VPN을 포함한 모든 인터페이스가 하나의 포트를 사용합니다. 공유할 실제 IP를 선택하세요. 끄면 IP별 포트가 복원됩니다.", + "de": "Ein Port für alle Schnittstellen, einschließlich VPN. Zum Teilen eine tatsächliche IP wählen. Ausschalten stellt die Ports pro IP wieder her.", + "fr": "Un port commun à toutes les interfaces, VPN inclus. Choisissez une IP réelle à partager. Désactivez pour rétablir les ports propres à chaque IP.", + "es": "Un puerto para todas las interfaces, incluidas las VPN. Seleccione una IP real para compartir. Desactive para restaurar los puertos de cada IP.", + "pt-BR": "Uma porta para todas as interfaces, incluindo VPNs. Selecione um IP real para compartilhar. Desative para restaurar as portas de cada IP." } }, "desktop": { @@ -10751,7 +10775,9 @@ "已连接", "未连接", "确认断开", - "确认执行" + "确认执行", + "监听所有 IPv4 接口", + "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。" ], "platforms": { "desktop": [ @@ -11233,7 +11259,9 @@ "验证尝试过于频繁,请稍后重试。", "高", "(仅在交互终端显示)", - ":同步键鼠到所有已连接设备" + ":同步键鼠到所有已连接设备", + "监听所有 IPv4 接口", + "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。" ], "android": [ " · 已完成",