From 7c6e14989f652119fae960c3798963131b72c9c7 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Thu, 29 Oct 2026 20:02:43 +0800 Subject: [PATCH] [feat] add IPv6 support for direct IP connections, refs #129 --- README.md | 4 +- README_EN.md | 4 +- .../src/gui/application/gui_application.cpp | 33 +++-- .../assets/localization/localization_data.h | 12 +- .../features/settings/settings_manager.cpp | 3 +- apps/desktop/src/gui/ui/lan_strings.slint | 2 +- apps/desktop/src/gui/ui/main_window.slint | 25 ++-- apps/desktop/src/network/lan/lan_identity.cpp | 132 +++++++++++------- apps/desktop/src/network/lan/lan_identity.h | 11 +- .../src/network/lan/lan_session_manager.cpp | 32 +++-- .../src/network/lan/lan_session_manager.h | 3 +- .../desktop/src/network/lan/lan_transport.cpp | 88 ++++++++---- apps/desktop/src/thumbnail/thumbnail.cpp | 3 +- deps/submodules/minirtc | 2 +- docs/USER_GUIDE.md | 10 +- docs/USER_GUIDE_EN.md | 10 +- docs/connection-architecture.md | 22 +-- localization/catalog.json | 78 +++++------ 18 files changed, 287 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index 95f1a0e9..be30f547 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,10 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 ### 通过局域网 IP 直连 1. **切换模式。** 两台电脑均运行支持 IP 直连的桌面客户端,点击左上角模式按钮切换到 **IP 直连模式**。若按钮未显示,在 **设置 → 常规 → 网络连接** 中勾选 **启用 IP 直连** 并保存。 -2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。 +2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888` 或 `[2001:db8::20]:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。 3. **输入密码。** 输入被控电脑在 IP 直连模式下显示的 **6 位英文字母或数字密码**,可勾选“记住密码”。切换模式时,“近期连接”同步切换为对应模式的设备记录。 -两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改端口和密码,默认开启所有 IPv4 接口监听,支持通过 VPN 等接口连接,可在设置的“网络连接”中调整;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。 +两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改端口和密码,默认开启所有 IP 接口监听,支持 IPv4 和 IPv6,支持通过 VPN 等接口连接,可在设置的“网络连接”中调整;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。 diff --git a/README_EN.md b/README_EN.md index 0aedb534..95e51c10 100644 --- a/README_EN.md +++ b/README_EN.md @@ -74,10 +74,10 @@ For self-hosting, both ends must use the same service. See the [user guide](docs ### Connect directly over LAN 1. **Switch modes.** Run a desktop client with direct IP support on both computers. Click the top-left mode button to select **Direct IP mode**. If the button is hidden, select **Enable direct IP** under **Settings → General → Network** and save. -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. +2. **Enter the address.** On the controller, enter the host's displayed `IP:port`, for example `192.168.1.20:52888` or `[2001:db8::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. **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). +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 IP interfaces** is enabled by default for IPv4, IPv6, 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 7a0259b5..e6f5c609 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1787,6 +1787,8 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { const auto port = UiText(std::to_string(lan_->PortFor(std::string(address)))); main->set_lan_listener_ip(address); + main->set_lan_listener_ipv6(std::string(address).find(':') != + std::string::npos); main->set_lan_listener_port(port); main->set_lan_local_port(port); main->set_lan_port_dirty(false); @@ -1808,6 +1810,8 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { lan_->Configure(std::string(address), port_number); const auto normalized_port = UiText(std::to_string(port_number)); main->set_lan_listener_ip(address); + main->set_lan_listener_ipv6(std::string(address).find(':') != + std::string::npos); main->set_lan_listener_port(normalized_port); main->set_lan_local_port(normalized_port); main->set_lan_port_dirty(false); @@ -2502,8 +2506,17 @@ void GuiApplication::Tick() { if (changed) { std::vector addresses; addresses.reserve(available_interfaces.size()); - for (const auto& network_interface : available_interfaces) - addresses.push_back(UiText(network_interface.address)); + std::vector labels; + labels.reserve(available_interfaces.size()); + for (const auto& network_interface : available_interfaces) { + const auto& ip = network_interface.address; + addresses.push_back(UiText(ip)); + labels.push_back( + UiText(ip.find(':') == std::string::npos ? ip : "[" + ip + "]")); + } + ui_->main->set_lan_interface_labels( + std::make_shared>( + std::move(labels))); ui_->main->set_lan_interfaces( std::make_shared>(std::move(addresses))); } @@ -2536,6 +2549,8 @@ void GuiApplication::Tick() { ui_->main->set_lan_local_port(port); } ui_->main->set_lan_listener_ip(UiText(display_address)); + ui_->main->set_lan_listener_ipv6(display_address.find(':') != + std::string::npos); ui_->main->set_lan_listener_port(port); ui_->main->set_lan_interface_index(interface_index); ui_->main->set_lan_interface_draft_index(interface_index); @@ -2551,7 +2566,7 @@ void GuiApplication::Tick() { !ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) { if (auto issue = lan_->TakeListenerWarning()) { ui_->main->set_lan_access_warning_endpoint( - UiText(issue->address + ":" + std::to_string(issue->port))); + UiText(lan::FormatEndpoint(issue->address, issue->port))); ui_->main->set_lan_access_warning(UiText(issue->error)); } } @@ -3392,9 +3407,10 @@ void GuiApplication::SyncMainWindow() { item.display_name = UiText(settings_.RecentConnectionDisplayName(connection)); item.lan = !connection.lan.device_id.empty(); - item.endpoint = UiText(item.lan ? connection.lan.address + ":" + - std::to_string(connection.lan.port) - : ""); + item.endpoint = + UiText(item.lan ? lan::FormatEndpoint(connection.lan.address, + connection.lan.port) + : ""); item.online = !item.lan && device_presence_cache_.IsOnline(connection.remote_id); item.connected = is_connected(connection.remote_id); @@ -4830,10 +4846,7 @@ void GuiApplication::ConnectFromUi(const std::string& input) { for (const auto& [_, recent] : recent_connections_) if (recent.remote_id == input && !recent.lan.device_id.empty()) { ui_->main->invoke_request_lan_connection( - UiText(recent.lan.address + - (recent.lan.port == 52888 - ? "" - : ":" + std::to_string(recent.lan.port)))); + UiText(lan::FormatEndpoint(recent.lan.address, recent.lan.port))); return; } return; diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 77056ec5..cbb96fea 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -406,8 +406,8 @@ inline constexpr TranslationRow kTranslationRows[] = { {"请输入六位密码:", {"请输入六位密码:", "Enter a six-character password:", "Введите пароль из 6 символов:", "請輸入六位密碼:", "6 文字のパスワードを入力します。", "6자리 비밀번호를 입력하세요.", "Geben Sie ein sechsstelliges Passwort ein:", "Saisissez un mot de passe à six caractères :", "Ingrese una contraseña de seis caracteres:", "Digite uma senha de seis caracteres:"}}, {"请输入密码:", {"请输入密码:", "Please input password:", "Введите пароль:", "請輸入密碼:", "パスワードを入力してください:", "비밀번호를 입력해주세요:", "Bitte geben Sie das Passwort ein:", "Veuillez saisir le mot de passe :", "Por favor ingrese la contraseña:", "Insira a senha:"}}, {"请输入有效的 9 位设备 ID", {"请输入有效的 9 位设备 ID", "Enter a valid 9-digit device ID", "Введите корректный 9-значный ID устройства", "請輸入有效的 9 位裝置 ID", "有効な9桁のデバイスIDを入力してください", "유효한 9자리 장치 ID를 입력하세요.", "Geben Sie eine gültige 9-stellige Geräte-ID ein", "Entrez un identifiant d'appareil valide à 9 chiffres", "Ingrese una ID de dispositivo válida de 9 dígitos", "Insira um ID de dispositivo válido de 9 dígitos"}}, - {"请输入有效的 IPv4 或 IPv4:端口", {"请输入有效的 IPv4 或 IPv4:端口", "Enter a valid IPv4 or IPv4:port", "Укажите IPv4 или IPv4:порт", "請輸入有效的 IPv4 或 IPv4:埠", "有効な IPv4 または IPv4:port を入力してください", "유효한 IPv4 또는 IPv4:포트를 입력하세요.", "Geben Sie einen gültigen IPv4- oder IPv4:Port ein", "Entrez un port IPv4 ou IPv4 valide", "Ingrese un IPv4 válido o IPv4:puerto", "Insira um IPv4 ou IPv4:porta válido"}}, - {"请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", {"请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", "Enter a valid IPv4 address such as 192.168.1.20, or IPv4:port (1–65535).", "Введите IPv4, например 192.168.1.20, или IPv4:порт (1–65535).", "請輸入有效的 IPv4,例如 192.168.1.20;指定埠可用 IPv4:埠,埠範圍 1–65535。", "192.168.1.20 または IPv4:port (1 ~ 65535) などの有効な IPv4 アドレスを入力します。", "192.168.1.20 또는 IPv4:포트(1~65535)와 같은 유효한 IPv4 주소를 입력하세요.", "Geben Sie eine gültige IPv4-Adresse ein, z. B. 192.168.1.20 oder IPv4:Port (1–65535).", "Saisissez une adresse IPv4 valide telle que 192.168.1.20 ou IPv4:port (1–65535).", "Ingrese una dirección IPv4 válida como 192.168.1.20 o IPv4:puerto (1–65535).", "Insira um endereço IPv4 válido, como 192.168.1.20 ou IPv4:porta (1–65535)."}}, + {"请输入有效的 IPv4 或 IPv6 地址", {"请输入有效的 IPv4 或 IPv6 地址", "Enter a valid IPv4 or IPv6 address", "Укажите адрес IPv4 или IPv6", "請輸入有效的 IPv4 或 IPv6 位址", "有効な IPv4 または IPv6 アドレスを入力してください", "올바른 IPv4 또는 IPv6 주소를 입력하세요", "Geben Sie eine gültige IPv4- oder IPv6-Adresse ein", "Saisissez une adresse IPv4 ou IPv6 valide", "Ingrese una dirección IPv4 o IPv6 válida", "Insira um endereço IPv4 ou IPv6 válido"}}, + {"请输入 IPv4 或 IPv6 地址;指定端口请用 IPv4:端口或 [IPv6]:端口,端口范围 1–65535。链路本地 IPv6 需带本机网卡作用域,如 fe80::1%14。", {"请输入 IPv4 或 IPv6 地址;指定端口请用 IPv4:端口或 [IPv6]:端口,端口范围 1–65535。链路本地 IPv6 需带本机网卡作用域,如 fe80::1%14。", "Enter IPv4 or IPv6; specify a port as IPv4:port or [IPv6]:port (1–65535). Link-local IPv6 needs a local interface zone, e.g. fe80::1%14.", "Введите IPv4 или IPv6. Порт: IPv4:порт или [IPv6]:порт (1–65535). Для link-local IPv6 укажите зону локального интерфейса, например fe80::1%14.", "請輸入 IPv4 或 IPv6 位址;指定連接埠請用 IPv4:埠或 [IPv6]:埠,範圍 1–65535。鏈路本地 IPv6 需帶本機網卡作用域,如 fe80::1%14。", "IPv4 または IPv6 を入力してください。ポートは IPv4:port または [IPv6]:port(1~65535)で指定します。リンクローカル IPv6 には自機のインターフェースゾーンが必要です(例: fe80::1%14)。", "IPv4 또는 IPv6를 입력하세요. 포트는 IPv4:포트 또는 [IPv6]:포트(1–65535)로 지정합니다. 링크 로컬 IPv6에는 로컬 인터페이스 영역이 필요합니다(예: fe80::1%14).", "IPv4 oder IPv6 eingeben; Port als IPv4:Port oder [IPv6]:Port (1–65535). Link-lokales IPv6 benötigt die lokale Schnittstellenzone, z. B. fe80::1%14.", "Saisissez IPv4 ou IPv6 ; port : IPv4:port ou [IPv6]:port (1–65535). IPv6 local au lien nécessite la zone de votre interface locale, par exemple fe80::1%14.", "Ingrese IPv4 o IPv6; puerto: IPv4:puerto o [IPv6]:puerto (1–65535). IPv6 local de enlace requiere la zona de la interfaz local, p. ej. fe80::1%14.", "Insira IPv4 ou IPv6; porta: IPv4:porta ou [IPv6]:porta (1–65535). IPv6 local de enlace exige a zona da interface local, por exemplo fe80::1%14."}}, {"请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", {"请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", "Enter a valid host, IP, or HTTPS/WSS signaling URL. Paths are supported.", "Введите имя хоста, IP или HTTPS/WSS-адрес сигнального сервиса. Путь допустим.", "請輸入有效的主機名、IP 或 HTTPS/WSS 信令地址,可包含路徑。", "有効なホスト、IP、または HTTPS/WSS シグナリング URL を入力します。パスがサポートされています。", "유효한 호스트, IP 또는 HTTPS/WSS 신호 URL을 입력하세요. 경로가 지원됩니다.", "Geben Sie eine gültige Host-, IP- oder HTTPS/WSS-Signalisierungs-URL ein. Pfade werden unterstützt.", "Entrez une URL de signalisation d'hôte, d'adresse IP ou HTTPS/WSS valide. Les chemins sont pris en charge.", "Ingrese un host válido, IP o URL de señalización HTTPS/WSS. Se admiten caminos.", "Insira um URL de sinalização de host, IP ou HTTPS/WSS válido. Caminhos são suportados."}}, {"请输入连接名称:", {"请输入连接名称:", "Please input connection name:", "Введите имя подключения:", "請輸入連線名稱:", "接続名を入力してください:", "연결 이름을 입력하세요:", "Bitte geben Sie den Verbindungsnamen ein:", "Veuillez saisir le nom de la connexion :", "Ingrese el nombre de la conexión:", "Insira o nome da conexão:"}}, {"请连接 Wi-Fi 或网线,", {"请连接 Wi-Fi 或网线,", "Connect to Wi-Fi or Ethernet,", "Подключитесь к Wi-Fi или Ethernet", "請連線 Wi-Fi 或網線,", "Wi-Fiまたはイーサネットに接続し、", "Wi-Fi 또는 이더넷에 연결하고,", "Mit WLAN oder Ethernet verbinden,", "Connectez-vous au Wi-Fi ou à Ethernet,", "Conéctese a Wi-Fi o Ethernet,", "Conecte-se a Wi-Fi ou Ethernet,"}}, @@ -492,7 +492,7 @@ 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"}}, + {"监听所有 IP 接口", {"监听所有 IP 接口", "Listen on all IP interfaces", "Все IP-интерфейсы", "監聽所有 IP 介面", "すべての IP で待ち受け", "모든 IP 인터페이스 수신", "Alle IP-Schnittstellen", "Toutes les interfaces IP", "Todas las interfaces IP", "Todas as interfaces IP"}}, {"所有接口共用一个端口,包括 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."}}, }; @@ -799,7 +799,7 @@ inline constexpr const char* kSlintLiterals[] = { "设备正忙,请稍后重试。", "访问密码必须为 6 位英文字母或数字,区分大小写。", "无法连接,请检查对方 IP、程序是否运行,以及防火墙是否放行。", - "请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", + "请输入 IPv4 或 IPv6 地址;指定端口请用 IPv4:端口或 [IPv6]:端口,端口范围 1–65535。链路本地 IPv6 需带本机网卡作用域,如 fe80::1%14。", "请输入 1–65535 之间的整数端口。", "请点击主界面密码旁的修改按钮设置密码。", "IP 连接数量已达上限,请先断开部分连接后重试。", @@ -837,7 +837,7 @@ inline constexpr const char* kSlintLiterals[] = { " 台", "设备 / ID", "桌面墙", - "请输入有效的 IPv4 或 IPv4:端口", + "请输入有效的 IPv4 或 IPv6 地址", "正在连接…", "全部设备", "正在读取…", @@ -988,7 +988,7 @@ inline constexpr const char* kSlintLiterals[] = { "未连接", "确认断开", "确认执行", - "监听所有 IPv4 接口", + "监听所有 IP 接口", "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", }; } // namespace detail diff --git a/apps/desktop/src/gui/features/settings/settings_manager.cpp b/apps/desktop/src/gui/features/settings/settings_manager.cpp index 36c2ac68..030bd8cc 100644 --- a/apps/desktop/src/gui/features/settings/settings_manager.cpp +++ b/apps/desktop/src/gui/features/settings/settings_manager.cpp @@ -27,6 +27,7 @@ #endif #include "localization.h" +#include "network/lan/lan_identity.h" #include "rd_log.h" #include "runtime/gui_runtime.h" @@ -742,7 +743,7 @@ std::string SettingsManager::RecentConnectionDisplayName( return connection.remote_host_name; } if (!connection.lan.device_id.empty()) - return connection.lan.address + ":" + std::to_string(connection.lan.port); + return lan::FormatEndpoint(connection.lan.address, connection.lan.port); return connection.remote_id; } diff --git a/apps/desktop/src/gui/ui/lan_strings.slint b/apps/desktop/src/gui/ui/lan_strings.slint index 61b394cc..df52a3bb 100644 --- a/apps/desktop/src/gui/ui/lan_strings.slint +++ b/apps/desktop/src/gui/ui/lan_strings.slint @@ -40,7 +40,7 @@ export global LanStrings { if code == "Busy" { return GalleryStrings.texts[14]; } if code == "LAN password must contain exactly 6 ASCII letters or digits" { return GalleryStrings.texts[15]; } if code == "ConnectionFailed" || code == "ConnectionTimeout" || code == "IceConnectionTimeout" || code == "Timeout" || code == "ConnectionClosed" || code == "HeartbeatTimeout" || code == "SignalSendFailed" { return GalleryStrings.texts[16]; } - if code == "Enter a numeric IPv4 address or IPv4:port" || code == "Invalid LAN endpoint" { return GalleryStrings.texts[17]; } + if code == "Invalid LAN endpoint" { return GalleryStrings.texts[17]; } if code == "Invalid LAN port" { return GalleryStrings.texts[18]; } if code == "LAN password is not ready" { return GalleryStrings.texts[19]; } if code == "LAN connection limit reached" { return GalleryStrings.texts[20]; } diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index d8286361..ab823f9b 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -454,7 +454,7 @@ export component MainWindow inherits Window { root.lan-access-password = ""; root.lan-remember-password = false; let error = root.lan-prepare-connect(address); - root.connection-input-error = error == "Enter a numeric IPv4 address or IPv4:port" + root.connection-input-error = error == "Invalid LAN endpoint" ? GalleryStrings.texts[55] : LanStrings.error-message(error); if error == "" { @@ -467,9 +467,11 @@ 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-ipv6; in-out property lan-listener-port: "52888"; - out property lan-listener-address: root.lan-listener-ip == "" ? "" : root.lan-listener-ip + ":" + root.lan-listener-port; + out property lan-listener-address: root.lan-listener-ip == "" ? "" : (root.lan-listener-ipv6 ? "[" + root.lan-listener-ip + "]" : root.lan-listener-ip) + ":" + root.lan-listener-port; in-out property <[string]> lan-interfaces; + in-out property <[string]> lan-interface-labels: root.lan-interfaces; in-out property lan-interface-index: 0; in-out property lan-interface-draft-index: 0; in-out property lan-local-port: "52888"; @@ -497,7 +499,7 @@ export component MainWindow inherits Window { root.lan-port-dirty = false; return true; } - root.lan-access-warning-endpoint = root.lan-listener-ip + root.lan-access-warning-endpoint = (root.lan-listener-ipv6 ? "[" + root.lan-listener-ip + "]" : root.lan-listener-ip) + (root.lan-endpoint-error == "Invalid LAN port" ? "" : ":" + root.lan-local-port); root.lan-access-warning = root.lan-endpoint-error; return false; @@ -1294,13 +1296,13 @@ export component MainWindow inherits Window { } local-address-field := FlatSurface { x: 16px; y: 90px; width: parent.width - 32px; height: 40px; - // Size the endpoint as one line; reserve five port digits so - // editing never shifts the IP or changes the font size. - private property lan-font-scale: min(1, - (self.width - 80px) / (lan-ip-metrics.preferred-width + lan-port-metrics.preferred-width)); + // Reserve five port digits. Long IPv6 addresses elide at a + // readable size; the picker and Copy retain the full value. + private property lan-font-scale: max(0.75, min(1, + (self.width - 80px) / (lan-ip-metrics.preferred-width + lan-port-metrics.preferred-width))); lan-ip-metrics := Text { visible: false; accessible-role: none; - text: root.lan-interfaces[root.lan-interface-draft-index]; + text: root.lan-interface-labels[root.lan-interface-draft-index]; font-size: 18px; font-weight: 600; } lan-port-metrics := Text { @@ -1321,13 +1323,14 @@ export component MainWindow inherits Window { lan-ip-picker := GroupFilter { visible: root.lan-mode; x: 4px; y: 2px; - width: lan-ip-metrics.preferred-width * local-address-field.lan-font-scale + 16px; + width: min(lan-ip-metrics.preferred-width * local-address-field.lan-font-scale + 16px, + parent.width - 64px - lan-port-metrics.preferred-width * local-address-field.lan-font-scale); height: parent.height - 4px; embedded: true; text-size: 18px * local-address-field.lan-font-scale; text-weight: 600; text-color: WorkbenchStyle.text; show-indicator: false; - popup-min-width: 160px; - model: root.lan-interfaces; system-count: 0; + popup-min-width: max(160px, min(root.width - 80px, lan-ip-metrics.preferred-width + 40px)); + model: root.lan-interface-labels; system-count: 0; current-index <=> root.lan-interface-draft-index; enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0; normal-outline-color: UiSurface.edge; diff --git a/apps/desktop/src/network/lan/lan_identity.cpp b/apps/desktop/src/network/lan/lan_identity.cpp index d86ce328..eea63835 100644 --- a/apps/desktop/src/network/lan/lan_identity.cpp +++ b/apps/desktop/src/network/lan/lan_identity.cpp @@ -13,6 +13,7 @@ #include #include +#include "direct_address.h" #include "lan_password.h" #ifdef _WIN32 // Winsock must precede windows.h; the networking/ACL headers need its types. @@ -373,53 +374,83 @@ unsigned ParsePort(const std::string& value) { "Invalid LAN port"); return port; } -bool IsIpv4(const std::string& value) { - unsigned octets[4]{}; - size_t offset = 0; - for (unsigned i = 0; i < 4; ++i) { - size_t end = value.find('.', offset); - if (i == 3) end = value.size(); - if (end == std::string::npos || end <= offset || end - offset > 3 || - (end - offset > 1 && value[offset] == '0')) - return false; - for (size_t j = offset; j < end; ++j) { - if (value[j] < '0' || value[j] > '9') return false; - octets[i] = octets[i] * 10 + value[j] - '0'; - } - if (octets[i] > 255) return false; - offset = end + 1; - } - return offset == value.size() + 1 && octets[0] > 0 && octets[0] != 127 && - octets[0] < 224; +bool IsIpAddress(const std::string& value) { + return minirtc::ParseDirectAddress(value).has_value(); +} +std::string NormalizeAddress(const std::string& value) { + const auto address = minirtc::ParseDirectAddress(value); + Require(address.has_value(), "Invalid LAN endpoint"); + return minirtc::DirectAddressString(*address); +} +std::string FormatEndpoint(const std::string& address, unsigned port) { + return (address.find(':') == std::string::npos ? address + : "[" + address + "]") + + ":" + std::to_string(port); } std::pair ParseEndpoint(const std::string& value, unsigned default_port) { std::string address = value; - const auto colon = address.find(':'); unsigned port = default_port; - if (colon != std::string::npos) { - port = ParsePort(address.substr(colon + 1)); + if (!value.empty() && value.front() == '[') { + const auto close = value.find(']'); + Require(close != std::string::npos, "Invalid LAN endpoint"); + address = value.substr(1, close - 1); + Require(address.find(':') != std::string::npos, "Invalid LAN endpoint"); + if (close + 1 < value.size()) { + Require(value[close + 1] == ':', "Invalid LAN endpoint"); + port = ParsePort(value.substr(close + 2)); + } + } else if (const auto colon = value.find(':'); + colon != std::string::npos && + value.find(':', colon + 1) == std::string::npos) { + port = ParsePort(value.substr(colon + 1)); address.resize(colon); } - Require(IsIpv4(address) && port > 0 && port <= 65535, - "Enter a numeric IPv4 address or IPv4:port"); - return {address, port}; + Require(IsIpAddress(address) && port > 0 && port <= 65535, + "Invalid LAN endpoint"); + return {NormalizeAddress(address), port}; } -std::vector LocalIpv4Interfaces(bool physical_only) { +std::vector LocalIpInterfaces(bool physical_only) { std::vector result; + const auto append = [&](const sockaddr* native, unsigned index) { + char text[INET6_ADDRSTRLEN]{}; + std::string address; + if (native->sa_family == AF_INET) { + if (!inet_ntop(AF_INET, + &reinterpret_cast(native)->sin_addr, + text, sizeof(text))) + return; + address = text; + } else if (native->sa_family == AF_INET6) { + auto v6 = *reinterpret_cast(native); + if (IN6_IS_ADDR_LINKLOCAL(&v6.sin6_addr)) { + if (!v6.sin6_scope_id) v6.sin6_scope_id = index; +#ifdef __APPLE__ + // getifaddrs may expose the BSD embedded link scope as well. + v6.sin6_addr.s6_addr[2] = v6.sin6_addr.s6_addr[3] = 0; +#endif + } else { + v6.sin6_scope_id = 0; + } + if (!inet_ntop(AF_INET6, &v6.sin6_addr, text, sizeof(text))) return; + address = text; + if (v6.sin6_scope_id) address += "%" + std::to_string(v6.sin6_scope_id); + } + if (IsIpAddress(address)) result.push_back({NormalizeAddress(address)}); + }; #ifdef _WIN32 ULONG bytes = 16384; std::vector buffer(bytes); auto* adapters = reinterpret_cast(buffer.data()); ULONG code = - GetAdaptersAddresses(AF_INET, + GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, nullptr, adapters, &bytes); if (code == ERROR_BUFFER_OVERFLOW) { buffer.resize(bytes); adapters = reinterpret_cast(buffer.data()); - code = GetAdaptersAddresses(AF_INET, 0, nullptr, adapters, &bytes); + code = GetAdaptersAddresses(AF_UNSPEC, 0, nullptr, adapters, &bytes); } if (code != NO_ERROR) return result; for (auto* a = adapters; a; a = a->Next) { @@ -438,21 +469,19 @@ std::vector LocalIpv4Interfaces(bool physical_only) { continue; } for (auto* u = a->FirstUnicastAddress; u; u = u->Next) { - if (!u->Address.lpSockaddr || u->Address.lpSockaddr->sa_family != AF_INET) + if (!u->Address.lpSockaddr || (u->DadState != IpDadStatePreferred && + u->DadState != IpDadStateDeprecated)) continue; - char address[INET_ADDRSTRLEN]{}; - inet_ntop( - AF_INET, - &reinterpret_cast(u->Address.lpSockaddr)->sin_addr, - address, sizeof(address)); - if (IsIpv4(address)) result.push_back({address}); + append(u->Address.lpSockaddr, a->Ipv6IfIndex); } } #else ifaddrs* list = nullptr; 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 || + if (!a->ifa_addr || + (a->ifa_addr->sa_family != AF_INET && + a->ifa_addr->sa_family != AF_INET6) || !(a->ifa_flags & IFF_UP) || (a->ifa_flags & IFF_LOOPBACK) || (physical_only && (a->ifa_flags & IFF_POINTOPOINT))) continue; @@ -470,15 +499,15 @@ std::vector LocalIpv4Interfaces(bool physical_only) { a->ifa_name / "device"); #endif if (physical_only && !physical) continue; - char address[INET_ADDRSTRLEN]{}; - inet_ntop(AF_INET, &reinterpret_cast(a->ifa_addr)->sin_addr, - address, sizeof(address)); - if (IsIpv4(address)) result.push_back({address}); + append(a->ifa_addr, if_nametoindex(a->ifa_name)); } freeifaddrs(list); #endif - std::sort(result.begin(), result.end(), - [](const auto& a, const auto& b) { return a.address < b.address; }); + std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) { + const bool a_v6 = a.address.find(':') != std::string::npos; + const bool b_v6 = b.address.find(':') != std::string::npos; + return a_v6 != b_v6 ? !a_v6 : a.address < b.address; + }); result.erase(std::unique(result.begin(), result.end(), [](const auto& a, const auto& b) { return a.address == b.address; @@ -549,7 +578,8 @@ void Identity::Load() { !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"); + Require(address.empty() || IsIpAddress(address), + "Invalid LAN listener address"); if (!settings.contains("ports")) { settings["ports"] = nlohmann::json::object(); if (!address.empty()) @@ -558,8 +588,14 @@ void Identity::Load() { : nlohmann::json(52888u); } Require(settings["ports"].is_object(), "Invalid LAN port settings"); + // Preserve the shared port when upgrading the IPv4 wildcard to dual stack. + if (settings["ports"].contains("0.0.0.0")) { + if (!settings["ports"].contains(kAllInterfaces)) + settings["ports"][kAllInterfaces] = settings["ports"]["0.0.0.0"]; + settings["ports"].erase("0.0.0.0"); + } for (const auto& [ip, port] : settings["ports"].items()) - Require((IsIpv4(ip) || ip == kAllIpv4Interfaces) && + Require((IsIpAddress(ip) || ip == kAllInterfaces) && port.is_number_integer() && port > 0 && port <= 65535, "Invalid LAN port settings"); settings["address"] = address; @@ -668,15 +704,15 @@ bool Identity::VerifyPassword(const nlohmann::json& verifier, } void Identity::SaveSettings(const std::string& address, unsigned port) { - Require((IsIpv4(address) || address == kAllIpv4Interfaces) && port > 0 && + Require((IsIpAddress(address) || address == kAllInterfaces) && port > 0 && port <= 65535, "Invalid LAN listener address"); auto next = state_; - const auto interfaces = LocalIpv4Interfaces(); + const auto interfaces = LocalIpInterfaces(); const auto selected = std::find_if(interfaces.begin(), interfaces.end(), [&](const auto& i) { return i.address == address; }); - Require(address == kAllIpv4Interfaces || selected != interfaces.end(), + Require(address == kAllInterfaces || selected != interfaces.end(), "Selected LAN interface is unavailable"); next["settings"]["ports"][address] = port; Save(next); @@ -689,7 +725,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 = LocalIpv4Interfaces(!listen_all()); + const auto interfaces = LocalIpInterfaces(!listen_all()); Require(std::any_of(interfaces.begin(), interfaces.end(), [&](const auto& i) { return i.address == address; }), "Selected LAN interface is unavailable"); @@ -704,7 +740,7 @@ void Identity::SaveListenerMode(bool listen_all, auto next = state_; next["settings"]["listen_all"] = listen_all; for (const auto& [address, port] : ports) { - Require((IsIpv4(address) || address == kAllIpv4Interfaces) && port > 0 && + Require((IsIpAddress(address) || address == kAllInterfaces) && port > 0 && port <= 65535, "Invalid LAN listener address"); next["settings"]["ports"][address] = port; diff --git a/apps/desktop/src/network/lan/lan_identity.h b/apps/desktop/src/network/lan/lan_identity.h index 043ca0ad..48526884 100644 --- a/apps/desktop/src/network/lan/lan_identity.h +++ b/apps/desktop/src/network/lan/lan_identity.h @@ -28,10 +28,13 @@ struct Target { unsigned ParsePort(const std::string& value); std::pair ParseEndpoint(const std::string& value, unsigned default_port); -bool IsIpv4(const std::string& value); -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); +bool IsIpAddress(const std::string& value); +std::string NormalizeAddress(const std::string& value); +std::string FormatEndpoint(const std::string& address, unsigned port); +inline constexpr char kAllInterfaces[] = "::"; +// Non-loopback unicast IPv4/IPv6 addresses, optionally restricted to physical +// NICs. +std::vector LocalIpInterfaces(bool physical_only = false); std::string RandomHex(size_t bytes); bool ValidPassword(const std::string& password); bool VerifyLanCertificate(X509* cert); diff --git a/apps/desktop/src/network/lan/lan_session_manager.cpp b/apps/desktop/src/network/lan/lan_session_manager.cpp index 90fdb376..3223fe3e 100644 --- a/apps/desktop/src/network/lan/lan_session_manager.cpp +++ b/apps/desktop/src/network/lan/lan_session_manager.cpp @@ -25,12 +25,14 @@ bool Id(const Json& value) { }); } Json Capabilities() { - return Json::array({"host-only-ipv4", "dtls-srtp-all", "password-auth-v1"}); + return Json::array({"host-only-ipv4", "host-only-ipv6", "dtls-srtp-all", + "password-auth-v1"}); } -void Capable(const Json& j) { +void Capable(const Json& j, bool ipv6) { const auto& caps = j.at("capabilities"); Check(caps.is_array() && caps.size() <= 16); - for (const auto& required : Capabilities()) + for (const auto* required : {ipv6 ? "host-only-ipv6" : "host-only-ipv4", + "dtls-srtp-all", "password-auth-v1"}) Check(std::find(caps.begin(), caps.end(), required) != caps.end()); } void Wipe(std::string& text) { @@ -123,14 +125,14 @@ 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(identity_.listen_all() ? kAllIpv4Interfaces : address, port, + ReplaceListener(identity_.listen_all() ? kAllInterfaces : 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 interfaces = LocalIpInterfaces(!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. @@ -144,7 +146,7 @@ void SessionManager::SetListenAll(bool enabled) { ports[address] = listeners_.at(address).port; }; if (enabled) - add(kAllIpv4Interfaces); + add(kAllInterfaces); else for (const auto& interface : interfaces) add(interface.address); // Publish the mode and all newly allocated ports in one durable write. @@ -263,8 +265,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( - identity_.listen_all() ? kAllIpv4Interfaces : address); + const auto it = + listener_errors_.find(identity_.listen_all() ? kAllInterfaces : address); return it == listener_errors_.end() ? "" : it->second.error; } std::optional SessionManager::TakeListenerWarning() { @@ -277,7 +279,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; + if (identity_.listen_all()) return address == kAllInterfaces; return std::any_of(interfaces_.begin(), interfaces_.end(), [&](const auto& i) { return i.address == address; }); }; @@ -298,7 +300,7 @@ void SessionManager::RefreshListeners() { it = listener_errors_.erase(it); } const auto desired = identity_.listen_all() - ? std::vector{{kAllIpv4Interfaces}} + ? std::vector{{kAllInterfaces}} : interfaces_; for (const auto& interface : desired) { const auto port = identity_.PortFor(interface.address); @@ -354,7 +356,7 @@ bool SessionManager::Send(const SessionPtr& s, Json j, bool sequenced) { int SessionManager::RetryAfter(const std::string& address, unsigned port) const { const auto [ip, endpoint_port] = ParseEndpoint(address, port); - const auto it = retry_at_.find(ip + ":" + std::to_string(endpoint_port)); + const auto it = retry_at_.find(FormatEndpoint(ip, endpoint_port)); const auto now = Clock::now(); return it == retry_at_.end() || it->second <= now ? 0 @@ -446,7 +448,7 @@ void SessionManager::Message(const SessionPtr& s, const std::string& text) { else ++it; if (retry_at_.size() < 64) - retry_at_[s->target.address + ":" + std::to_string(s->target.port)] = + retry_at_[FormatEndpoint(s->target.address, s->target.port)] = now + std::chrono::seconds(j["retry_after"].get()); } Close(s, error); @@ -477,7 +479,7 @@ void SessionManager::Message(const SessionPtr& s, const std::string& text) { Check(type == "auth" && !j.contains("session_id") && !j.contains("seq") && Id(j.at("request_id")) && j.at("password").is_string() && !j.contains("share") && !j.contains("proof")); - Capable(j); + Capable(j, s->channel->remote_address.find(':') != std::string::npos); s->auth_request = j.at("request_id"); auto& submitted = j["password"].get_ref(); if (!ValidPassword(submitted)) { @@ -814,9 +816,9 @@ void SessionManager::Tick() { } if (!stopping_ && now - interface_check_ >= std::chrono::seconds(2)) { interface_check_ = now; - const auto local_interfaces = LocalIpv4Interfaces(); + const auto local_interfaces = LocalIpInterfaces(); interfaces_ = - identity_.listen_all() ? local_interfaces : LocalIpv4Interfaces(true); + identity_.listen_all() ? local_interfaces : LocalIpInterfaces(true); RefreshListeners(); for (auto& [_, s] : sessions_) if (s->phase != Session::Connecting && diff --git a/apps/desktop/src/network/lan/lan_session_manager.h b/apps/desktop/src/network/lan/lan_session_manager.h index d72a7953..2429d2ea 100644 --- a/apps/desktop/src/network/lan/lan_session_manager.h +++ b/apps/desktop/src/network/lan/lan_session_manager.h @@ -81,8 +81,7 @@ class SessionManager { 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); + return identity_.PortFor(identity_.listen_all() ? kAllInterfaces : address); } void Configure(const std::string& address, unsigned port); bool password_pending() const { return reset_job_.valid(); } diff --git a/apps/desktop/src/network/lan/lan_transport.cpp b/apps/desktop/src/network/lan/lan_transport.cpp index fe34de4c..e99367b5 100644 --- a/apps/desktop/src/network/lan/lan_transport.cpp +++ b/apps/desktop/src/network/lan/lan_transport.cpp @@ -13,6 +13,8 @@ #include #include +#include "direct_address.h" + namespace crossdesk::lan { namespace { using Clock = std::chrono::steady_clock; @@ -25,15 +27,20 @@ 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"); - if (address == kAllIpv4Interfaces) return; - const auto interfaces = LocalIpv4Interfaces(); - if (!IsIpv4(address) || + if (address == kAllInterfaces) return; + const auto interfaces = LocalIpInterfaces(); + if (!IsIpAddress(address) || std::none_of(interfaces.begin(), interfaces.end(), [&](const auto& i) { return i.address == address; })) throw std::runtime_error("Selected LAN interface is unavailable"); } -Error ConfigureAcceptor(asio::ip::tcp::acceptor& acceptor, bool automatic_port) { +Error ConfigureAcceptor(asio::ip::tcp::acceptor& acceptor, bool automatic_port, + bool ipv6, bool dual_stack) { Error error; + if (ipv6) { + acceptor.set_option(asio::ip::v6_only(!dual_stack), error); + if (error) return error; + } #ifdef _WIN32 (void)automatic_port; acceptor.set_option(asio::socket_base::reuse_address(false), error); @@ -69,7 +76,7 @@ void CheckExistingEndpoint(const std::string& address, unsigned port) { Error result; bool timed_out = false; socket.async_connect( - {asio::ip::make_address_v4(address), static_cast(port)}, + {asio::ip::make_address(address), static_cast(port)}, [&](const Error& error) { result = error; deadline.cancel(); @@ -91,23 +98,27 @@ void CheckExistingEndpoint(const std::string& address, unsigned port) { } void CheckExistingListener(const std::string& address, unsigned port) { #ifdef __APPLE__ - if (address == kAllIpv4Interfaces) { + if (address == kAllInterfaces || address == "0.0.0.0") { // 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(); + auto addresses = LocalIpInterfaces(); addresses.push_back({"127.0.0.1"}); + if (address == kAllInterfaces) addresses.push_back({"::1"}); for (const auto& interface : addresses) { + const auto ip = asio::ip::make_address(interface.address); + if (address == "0.0.0.0" && ip.is_v6()) continue; asio::ip::tcp::acceptor probe(io); Error error; - probe.open(asio::ip::tcp::v4(), error); + probe.open(ip.is_v6() ? asio::ip::tcp::v6() : asio::ip::tcp::v4(), error); + CheckListenError(error); + if (ip.is_v6()) probe.set_option(asio::ip::v6_only(true), 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); + probe.bind({ip, static_cast(port)}, error); CheckListenError(error); } return; @@ -115,6 +126,29 @@ void CheckExistingListener(const std::string& address, unsigned port) { #endif CheckExistingEndpoint(address, port); } +// Fall back only when the host has no usable IPv6 stack. A busy requested +// port must still be reported, never silently degrade a dual-stack listener. +bool SupportsDualStack() { + asio::io_context io; + asio::ip::tcp::acceptor probe(io); + Error error; + probe.open(asio::ip::tcp::v6(), error); + if (!error) probe.set_option(asio::ip::v6_only(false), error); + if (!error) probe.bind({asio::ip::address_v6::any(), 0}, error); + if (error == asio::error::address_family_not_supported || + error == std::errc::protocol_not_supported || + error == asio::error::no_protocol_option || + error == asio::error::operation_not_supported || + error == std::errc::address_not_available) + return false; + CheckListenError(error); + return true; +} +std::string SocketAddress(asio::ip::address address) { + if (address.is_v6() && address.to_v6().is_v4_mapped()) + address = asio::ip::make_address_v4(asio::ip::v4_mapped, address.to_v6()); + return minirtc::DirectAddressString(address); +} std::shared_ptr TlsContext() { auto context = std::make_shared(Context::tls); if (SSL_CTX_set_min_proto_version(context->native_handle(), TLS1_3_VERSION) != @@ -286,14 +320,10 @@ struct Transport::Impl { Abort(c); return; } - if (!local.address().is_v4() || !remote.address().is_v4()) { - Abort(c); - return; - } - c->local_address = local.address().to_string(); - c->remote_address = remote.address().to_string(); - const auto interfaces = LocalIpv4Interfaces(); - if (!IsIpv4(c->remote_address) || + c->local_address = SocketAddress(local.address()); + c->remote_address = SocketAddress(remote.address()); + const auto interfaces = LocalIpInterfaces(); + if (!minirtc::IsDirectAddressPair(c->local_address, c->remote_address) || std::none_of( interfaces.begin(), interfaces.end(), [&](const auto& i) { return i.address == c->local_address; })) { @@ -389,7 +419,10 @@ Transport::Listener Transport::Listen(const Identity& identity, auto future = promise->get_future(); asio::post(impl_->io, [this, address, port, context, promise] { try { - if (port) CheckExistingListener(address, port); + const auto binding = address == kAllInterfaces && !SupportsDualStack() + ? std::string("0.0.0.0") + : address; + if (port) CheckExistingListener(binding, port); auto server = std::make_shared(); impl_->Configure(server.get()); const uint64_t id = impl_->next_id++; @@ -427,15 +460,16 @@ Transport::Listener Transport::Listen(const Identity& identity, }); auto bound_acceptor = std::make_shared(); server->set_tcp_pre_bind_handler( - [port, bound_acceptor](Server::acceptor_ptr acceptor) { + [port, binding, bound_acceptor](Server::acceptor_ptr acceptor) { *bound_acceptor = acceptor; - return ConfigureAcceptor(*acceptor, port == 0); + return ConfigureAcceptor(*acceptor, port == 0, + binding.find(':') != std::string::npos, + binding == kAllInterfaces); }); Error listen_error; - server->listen( - asio::ip::tcp::endpoint(asio::ip::make_address_v4(address), - static_cast(port)), - listen_error); + server->listen(asio::ip::tcp::endpoint(asio::ip::make_address(binding), + static_cast(port)), + listen_error); CheckListenError(listen_error); const auto actual_port = server->get_local_endpoint(listen_error).port(); CheckListenError(listen_error); @@ -474,7 +508,7 @@ void Transport::StopAccept(uint64_t listener) { }); } Transport::ChannelPtr Transport::Connect(const Target& target) { - if (!IsIpv4(target.address) || target.port == 0 || target.port > 65535) + if (!IsIpAddress(target.address) || target.port == 0 || target.port > 65535) throw std::runtime_error("Invalid LAN endpoint"); auto c = std::make_shared(); c->id = impl_->next_id++; @@ -505,7 +539,7 @@ Transport::ChannelPtr Transport::Connect(const Target& target) { client->set_tls_init_handler([context](Hdl) { return context; }); Error error; auto connection = client->get_connection( - "wss://" + target.address + ":" + std::to_string(target.port) + kPath, + "wss://" + FormatEndpoint(target.address, target.port) + kPath, error); if (error) { impl_->Finish(c); diff --git a/apps/desktop/src/thumbnail/thumbnail.cpp b/apps/desktop/src/thumbnail/thumbnail.cpp index dd4af952..89ad58a6 100644 --- a/apps/desktop/src/thumbnail/thumbnail.cpp +++ b/apps/desktop/src/thumbnail/thumbnail.cpp @@ -181,7 +181,8 @@ bool Thumbnail::ReadRecord(const std::filesystem::path& path, connection->lan.device_id.size() != 32 || connection->remote_id != LanRecordId(connection->lan.device_id) || !connection->lan.port || connection->lan.port > 65535 || - connection->lan.address.size() > 15) + connection->lan.address.empty() || + connection->lan.address.size() > 128) return false; } else if (kind != "cloud_id" || !IsRemoteId(connection->remote_id)) return false; diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index f2d034ed..52be3045 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit f2d034ed166f83a0b8b803207ecefa41036d8690 +Subproject commit 52be304543685fa9f52bda2268b3de6013f7cb5a diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b6751891..665bbbc2 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -52,20 +52,22 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-.deb" ### 通过局域网 IP 直连 -当前 IP 直连入口用于 Windows、macOS、Linux 的普通桌面客户端。两台电脑的 IPv4 地址需要可互相访问;有无外网均可,不需要等待连接信令服务器。浏览器、移动端、Windows 登录前服务及 Linux 无头控制台目前继续使用设备 ID 连接流程。 +当前 IP 直连入口用于 Windows、macOS、Linux 的普通桌面客户端。两台电脑的 IPv4 或 IPv6 地址需要可互相访问;有无外网均可,不需要等待连接信令服务器。浏览器、移动端、Windows 登录前服务及 Linux 无头控制台目前继续使用设备 ID 连接流程。 1. **启用并切换。** 两端在 **设置 → 常规 → 网络连接** 中启用 **IP 直连** 并保存(默认开启),再用主窗口左上角按钮切换到 **IP 直连模式**。 2. **获取对方地址。** 被控端在“本机 IP / 端口”查看地址,复制按钮会复制完整的 `IP:端口`。有多个地址时,选择控制端能够访问的地址。 -3. **连接并验证。** 控制端在“远端 IP / 端口”输入例如 `192.168.1.20:52888`,点击“连接”,再输入被控端此模式下的六位密码。省略端口时使用 **52888**;当前支持数字 IPv4,不支持主机名或 IPv6。 +3. **连接并验证。** 控制端在“远端 IP / 端口”输入例如 `192.168.1.20:52888` 或 `[2001:db8::20]:52888`,点击“连接”,再输入被控端此模式下的六位密码。省略端口时使用 **52888**;支持数字 IPv4 和 IPv6,不支持主机名。 4. **保存与重连。** 密码弹窗可勾选“记住密码”。近期连接按 ID / IP 模式分别显示;不勾选也会保存设备记录,下次连接需重新输入密码。可从卡片重连、修改别名或删除记录。

IP 直连密码弹窗与记住密码选项,背景为演示局域网设备

-**地址和端口:** 点击“切换 IP”选择展示与分享的地址,点击“修改端口”编辑该 IP 的端口。默认所有接口共用一个端口;关闭“监听所有 IPv4 接口”后,程序同时监听可用物理网卡上的 IPv4,每个 IP 单独保存端口。切换展示地址不会停用其他地址。首次为某个 IP 分配端口时优先使用 **52888**,被占用时自动分配并保存可用端口。已有配置的端口发生冲突时会提示,需手动修改。端口范围为 **1–65535**,修改失败会保留原设置;连接方需要使用界面显示的实际端口。局域网防火墙需允许 CrossDesk 的 TCP 协商连接和 UDP 远控流量。 +**地址和端口:** 点击“切换 IP”选择展示与分享的地址,点击“修改端口”编辑该 IP 的端口。默认所有接口共用一个端口;关闭“监听所有 IP 接口”后,程序同时监听可用物理网卡上的 IPv4 和 IPv6,每个 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 接口”并保存,即可让有线、Wi-Fi、VPN 和虚拟网卡的 IPv4/IPv6 共用一个 TCP 协商端口。此选项默认开启,已有的显式关闭设置会保留;修改后点击“保存”生效,点击“取消”不改变监听,重启仍沿用已保存设置;关闭后恢复原来各物理网卡 IP 的端口。该模式下修改任一展示地址的端口,会修改所有接口共用的端口。“切换 IP”和复制仍使用本机实际地址,不能把 `0.0.0.0` 或 `::` 作为远端地址。新接口自动使用共同端口;VPN 两端仍须允许 TCP 和 UDP 互通。不支持回环远控或自动公网 NAT 穿透。切换监听范围会取消未完成认证的连接;已认证连接保留。若端口被占用或设置无法保存,切换失败并尝试恢复原监听,恢复失败的端口会显示错误并持续重试。 + +**IPv6 地址:** 可以输入裸 IPv6 使用默认端口,例如 `2001:db8::20`;指定端口时使用方括号,例如 `[2001:db8::20]:52888`。支持全局、ULA 和链路本地单播地址。链路本地地址须带连接端自己的网卡作用域,例如 `[fe80::1234%14]:52888`(也可使用系统支持的网卡名,如 `%en0`)。复制的地址带有被控端作用域,控制端连接时需换成控制端用于访问该链路的网卡编号或名称,两端编号不一定相同。程序仍使用实际地址执行 TCP 认证与 UDP 媒体协商;IPv4 客户端可以继续连接新版。操作系统不支持 IPv6 时,所有接口监听自动退回 IPv4;升级保留原有共享端口与开关状态。 **密码:** IP 直连使用独立的 **6 位英文字母或数字密码,区分大小写**。没有设置时自动生成,眼睛、刷新和铅笔按钮分别用于显示 / 隐藏、随机生成和自定义;修改成功后即时生效,无需重启或连接服务器。已保存旧密码的控制端需要重新输入。 diff --git a/docs/USER_GUIDE_EN.md b/docs/USER_GUIDE_EN.md index 90f9acf9..21b6462b 100644 --- a/docs/USER_GUIDE_EN.md +++ b/docs/USER_GUIDE_EN.md @@ -52,20 +52,22 @@ The eye button shows or hides the local password, the refresh button generates a ### Connect directly over LAN -The direct IP entry is available in the regular Windows, macOS, and Linux desktop clients. The computers must have mutually reachable IPv4 addresses. Internet access is optional, and there is no need to wait for a signaling server connection. Browsers, mobile clients, the Windows pre-sign-in service, and the Linux headless console currently use the device ID workflow. +The direct IP entry is available in the regular Windows, macOS, and Linux desktop clients. The computers must have mutually reachable IPv4 or IPv6 addresses. Internet access is optional, and there is no need to wait for a signaling server connection. Browsers, mobile clients, the Windows pre-sign-in service, and the Linux headless console currently use the device ID workflow. 1. **Enable and switch.** On both computers, select **Enable direct IP** under **Settings → General → Network** and save (enabled by default). Use the top-left button to switch to **Direct IP mode**. 2. **Get the host address.** Read **Local IP / Port** on the host. The copy button copies the full `IP:port`. If several addresses are available, choose one reachable from the controller. -3. **Connect and verify.** Enter an address such as `192.168.1.20:52888` under **Remote IP / Port**, click **Connect**, and enter the host's six-character password for this mode. Omitting the port uses **52888**. Only numeric IPv4 addresses are currently supported, not hostnames or IPv6. +3. **Connect and verify.** Enter an address such as `192.168.1.20:52888` or `[2001:db8::20]:52888` under **Remote IP / Port**, click **Connect**, and enter the host's six-character password for this mode. Omitting the port uses **52888**. Numeric IPv4 and IPv6 addresses are supported; hostnames are not. 4. **Save and reconnect.** Select **Remember password** in the password dialog if needed. Recent connections are separated by ID / IP mode. Device records are kept even without a saved password; you will be prompted next time. Cards let you reconnect, rename devices, or remove records.

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. 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. +**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 IP interfaces** is disabled, the app listens on available physical-interface IPv4 and IPv6 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. +**Listen on all interfaces:** enable **Listen on all IP interfaces** under **Settings → General → Network** and save to share one TCP negotiation port across IPv4 and IPv6 on 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; neither `0.0.0.0` nor `::` is a remote connection address. New interfaces use the shared port automatically. Both VPN endpoints must allow TCP and UDP traffic. 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. + +**IPv6 addresses:** enter a bare address such as `2001:db8::20` for the default port, or `[2001:db8::20]:52888` for an explicit port. Global, ULA, and link-local unicast addresses are supported. Link-local addresses require the connecting computer's local interface zone, for example `[fe80::1234%14]:52888`, or an interface name supported by the OS such as `%en0`. A copied address contains the host's zone; replace it with the controller's interface index or name for that link, since the two machines may use different indices. TCP authentication and UDP media use the actual endpoints. Existing IPv4 clients remain compatible. All-interface listening falls back to IPv4 when the OS has no IPv6 support; upgrading preserves the shared port and saved preference. **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 0c9ffc09..1988d75b 100644 --- a/docs/connection-architecture.md +++ b/docs/connection-architecture.md @@ -12,11 +12,11 @@ | 项目 | ID 连接模式 | IP 直连模式 | | --- | --- | --- | -| 用户输入 | 9 位设备 ID 和密码 | 数字 IPv4、可选端口和密码 | +| 用户输入 | 9 位设备 ID 和密码 | 数字 IPv4/IPv6、可选端口和密码 | | 设备寻址 | 信令服务维护的设备及在线状态 | 直接连接指定 `IP:端口` | | 密码验证位置 | 控制端提交 Join,由信令服务返回验证结果 | 被控端在本机验证收到的密码 | | 信令通道 | 双方连接官方或自建服务的 WSS | 控制端直接连接被控端的 WSS | -| ICE 策略 | `ManagedServers`,使用服务端下发的 ICE 配置 | `HostOnly`,限定本次 WSS 连接的双方 IPv4 | +| ICE 策略 | `ManagedServers`,使用服务端下发的 ICE 配置 | `HostOnly`,限定本次 WSS 连接的双方 IPv4/IPv6 | | 数据路径 | P2P 直连,或按配置与网络条件使用 TURN | 双方 UDP 直连,无 TURN 回退 | | 原生数据加密要求 | 按对端能力协商 SRTP,保留旧端兼容分支,见第 4.1 节 | 必须支持视频、音频及数据 SRTP,否则拒绝建立会话 | | 外网要求 | 取决于信令及 STUN/TURN 服务部署位置 | 直连会话不依赖外网服务 | @@ -112,17 +112,19 @@ LAN 协商通道使用 TLS 1.3、路径 `/crossdesk/lan/v2` 和 WebSocket 子协 `GuiRuntime::InitializeLan()` 在功能启用且运行宿主支持时创建 `lan::SessionManager`。管理器加载本地 LAN 身份、证书、密码和端口配置;尚无密码时先异步生成六位英文字母或数字密码,准备完成后开始接受连接。 -- `LocalIpv4Interfaces()` 枚举已启用接口上的非回环单播 IPv4,包含 VPN 和虚拟接口;关闭所有接口监听后的独立监听模式使用 `physical_only` 筛选物理网卡。当前只接收数字 IPv4 地址,不解析主机名或 IPv6。 -- 管理器约每 2 秒刷新接口快照。默认使用单个 `0.0.0.0` 监听,界面可选择包括 VPN 在内的实际 IPv4 分享;在“设置 → 常规 → 网络连接”中关闭“监听所有 IPv4 接口”并保存后,改为按物理网卡地址分别监听,界面也只列出这些地址。通配地址只用于绑定,不作为连接目标或 ICE 候选。 +- `LocalIpInterfaces()` 枚举已启用接口上的非回环单播 IPv4/IPv6,包含 VPN 和虚拟接口;关闭所有接口监听后的独立监听模式使用 `physical_only` 筛选物理网卡。只接收数字 IP 地址,不解析主机名;IPv6 规范化为压缩形式,链路本地地址保留本机 scope ID。 +- 管理器约每 2 秒刷新接口快照。默认使用单个 `::` 双栈监听(关闭 `IPV6_V6ONLY`),IPv6 不可用时退回 `0.0.0.0`,界面可选择包括 VPN 在内的实际 IPv4/IPv6 分享;在“设置 → 常规 → 网络连接”中关闭“监听所有 IP 接口”并保存后,改为按物理网卡地址分别监听,界面也只列出这些地址。通配地址只用于绑定,不作为连接目标或 ICE 候选。 - 每个 IP 首次配置优先尝试 `52888`;无法绑定时请求操作系统分配端口,并在保持 socket 占用期间保存实际端口,避免“探测后释放、使用时被抢占”的竞争。 - 已保存的端口冲突时报告错误并重试原端口,不自动换号。手动改端口按“绑定新端口 → 保存配置 → 替换监听”的顺序执行,失败时保留原设置。 - “切换 IP”只改变显示和分享的地址。端口修改会停止旧端口接受新连接,但保留已经完成认证的会话及其他 IP 的监听。 - 网卡下线或 DHCP 换址导致本地 IP 从接口快照中消失时,管理器停止该地址的监听,并以 `InterfaceRemoved` 关闭使用该本地 IP 的已有会话。其他 IP 的监听继续运行;当前直连协议只允许一轮 ICE 协商,不支持会话内 ICE 重启或无缝迁移到另一张网卡,需要重新建立连接。 -- 端口配置以 **IP 地址**为键,不跟随网卡标识。所有接口模式的端口单独保存在 `ports["0.0.0.0"]`,`listen_all` 保存模式,缺省为 `true`,已保存的 `false` 保留;设置页维护草稿,保存后应用,取消不改监听。切换不会覆盖各实际 IP 的端口。独立监听模式下,DHCP 分配未记录的新 IP 时,该地址重新执行“尝试 `52888` → 必要时分配可用端口”的首次配置流程;旧 IP 再次出现时沿用其保存的端口。 +- 端口配置以 **IP 地址**为键,不跟随网卡标识。所有接口模式的端口单独保存在 `ports["::"]`,升级时从旧 `ports["0.0.0.0"]` 继承,`listen_all` 保存模式,缺省为 `true`,已保存的 `false` 保留;设置页维护草稿,保存后应用,取消不改监听。切换不会覆盖各实际 IP 的端口。独立监听模式下,DHCP 分配未记录的新 IP 时,该地址重新执行“尝试 `52888` → 必要时分配可用端口”的首次配置流程;旧 IP 再次出现时沿用其保存的端口。 切换监听范围时先停止旧 acceptor,绑定目标监听并原子保存模式与新端口;失败时保留原设置并尝试重新绑定旧端口,恢复失败由后台维护继续重试并显示错误。已认证通道保留,未完成认证的通道关闭。所有接口模式在网卡增减时保持同一个监听,已有会话仍按其实际本地地址的存续状态处理。 -这里的 LAN 策略限定候选地址,不通过“是否有外网”判断工作模式,也不只是检查地址是否属于某个私网网段。实际连接仍要求本次 WSS 连接使用的双方 IPv4 的 TCP 和 UDP 路径可达。 +这里的 LAN 策略限定候选地址,不通过“是否有外网”判断工作模式,也不只是检查地址是否属于某个私网网段。实际连接仍要求本次 WSS 连接使用的双方 IPv4/IPv6 的 TCP 和 UDP 路径可达。 + +IPv6 端点以 `[地址%本机作用域]:端口` 显示和保存到分享文本,裸 IPv6 使用默认端口;近期记录分开保存规范化地址与端口。通配监听接收的 IPv4-mapped socket 地址还原成普通 IPv4;用户输入不接受 mapped、回环、未指定或组播地址。逐地址 IPv6 监听开启 `IPV6_V6ONLY`,避免与 IPv4 监听重叠。 ### 3.3 认证与协商时序 @@ -158,9 +160,11 @@ sequenceDiagram IP 直连由**控制端**发起 offer。双方内部 ID 和随机 `session_id` 用于本次会话路由,不需要用户填写设备 ID。协商消息带会话 ID 和严格递增序号;会话管理器检查状态顺序,再调用 `ReceiveDirectSignal()`。MiniRTC 进一步检查 SDP、候选数量、地址和 ICE 标识一致性,并去重候选。 -SessionManager 将当前 WSS socket 的本地与对端 IPv4 传入 `InitDirectPeer()`,由 MiniRTC 构造 `HostOnly` 策略,启动独立 ICE worker,不执行云端登录。MiniRTC 通过 `SignalSender` 回调把协商消息交给 SessionManager,再由 WSS 发送。 +SessionManager 将当前 WSS socket 的本地与对端 IPv4/IPv6 传入 `InitDirectPeer()`,由 MiniRTC 构造 `HostOnly` 策略,启动独立 ICE worker,不执行云端登录。MiniRTC 通过 `SignalSender` 回调把协商消息交给 SessionManager,再由 WSS 发送。 -MiniRTC 关闭 STUN/TURN 服务器访问、UPnP、ICE TCP 和公网打洞扩展,仅收集指定本机 IPv4 的 UDP host 候选。libnice 的 `direct-peer-address` 补丁同时检查远端候选和入站来源:在解析 STUN 连通性检查、学习新候选或回复数据之前,拒绝不匹配的 IP。ICE 自身的 STUN 检查报文仍会在双方之间使用,这与访问 STUN 服务器是两回事。 +MiniRTC 关闭 STUN/TURN 服务器访问、UPnP、ICE TCP 和公网打洞扩展,仅收集指定本机 IPv4/IPv6 的 UDP host 候选。libnice 的 `direct-peer-address` 补丁同时检查远端候选和入站来源:在解析 STUN 连通性检查、学习新候选或回复数据之前,拒绝不匹配的 IP。ICE 自身的 STUN 检查报文仍会在双方之间使用,这与访问 STUN 服务器是两回事。 + +IPv6 候选按地址字节比较,允许等价压缩写法;线上候选不携带网卡作用域。链路本地远端候选由 libnice 绑定到当前 WSS 连接确认的本机作用域,接收路径在学习候选前同时检查地址与作用域,防止跨网卡匹配。新补丁提供 `direct-peer-ipv6` 能力标志并更新包配置,旧 libnice 不会静默接受 IPv6 策略。认证能力列表增加 `host-only-ipv6`,IPv4 连接仍仅要求原有能力以兼容旧客户端。 WSS 在连接成功后继续承担心跳和会话关闭消息。当前心跳约每 5 秒发送一次,等待响应超过 15 秒时关闭通道;WSS 关闭会驱动对应的 MiniRTC 会话清理。 @@ -262,7 +266,7 @@ LAN 近期记录的设备 ID 用于归档,不用于确认对端身份。重连 ## 7. 支持范围与排查入口 -IP 直连目前面向 Windows、macOS、Linux 的普通桌面客户端。浏览器、原生移动端、Windows 登录前服务和 Linux 无头控制台没有这条直连入口。数字 IPv4 和 UDP host 候选是当前实现约束。所有接口模式可接受 VPN 或虚拟网卡上的连接,控制端也允许通过这些接口发起连接,但仍要求双方 TCP、UDP 路径可达;不提供主机名解析、IPv6、回环远控或公网 NAT 穿透。 +IP 直连目前面向 Windows、macOS、Linux 的普通桌面客户端。浏览器、原生移动端、Windows 登录前服务和 Linux 无头控制台没有这条直连入口。数字 IPv4/IPv6 和 UDP host 候选是当前实现约束。所有接口模式可接受 VPN 或虚拟网卡上的连接,控制端也允许通过这些接口发起连接,但仍要求双方 TCP、UDP 路径可达;不提供主机名解析、回环远控或公网 NAT 穿透。 | 现象 | 首先检查的层 | | --- | --- | diff --git a/localization/catalog.json b/localization/catalog.json index 0e2628cf..cb281831 100644 --- a/localization/catalog.json +++ b/localization/catalog.json @@ -8280,29 +8280,29 @@ "es": "Ingrese una ID de dispositivo válida de 9 dígitos", "pt-BR": "Insira um ID de dispositivo válido de 9 dígitos" }, - "请输入有效的 IPv4 或 IPv4:端口": { - "zh-Hans": "请输入有效的 IPv4 或 IPv4:端口", - "en": "Enter a valid IPv4 or IPv4:port", - "ru": "Укажите IPv4 или IPv4:порт", - "zh-Hant": "請輸入有效的 IPv4 或 IPv4:埠", - "ja": "有効な IPv4 または IPv4:port を入力してください", - "ko": "유효한 IPv4 또는 IPv4:포트를 입력하세요.", - "de": "Geben Sie einen gültigen IPv4- oder IPv4:Port ein", - "fr": "Entrez un port IPv4 ou IPv4 valide", - "es": "Ingrese un IPv4 válido o IPv4:puerto", - "pt-BR": "Insira um IPv4 ou IPv4:porta válido" + "请输入有效的 IPv4 或 IPv6 地址": { + "zh-Hans": "请输入有效的 IPv4 或 IPv6 地址", + "en": "Enter a valid IPv4 or IPv6 address", + "ru": "Укажите адрес IPv4 или IPv6", + "zh-Hant": "請輸入有效的 IPv4 或 IPv6 位址", + "ja": "有効な IPv4 または IPv6 アドレスを入力してください", + "ko": "올바른 IPv4 또는 IPv6 주소를 입력하세요", + "de": "Geben Sie eine gültige IPv4- oder IPv6-Adresse ein", + "fr": "Saisissez une adresse IPv4 ou IPv6 valide", + "es": "Ingrese una dirección IPv4 o IPv6 válida", + "pt-BR": "Insira um endereço IPv4 ou IPv6 válido" }, - "请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。": { - "zh-Hans": "请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", - "en": "Enter a valid IPv4 address such as 192.168.1.20, or IPv4:port (1–65535).", - "ru": "Введите IPv4, например 192.168.1.20, или IPv4:порт (1–65535).", - "zh-Hant": "請輸入有效的 IPv4,例如 192.168.1.20;指定埠可用 IPv4:埠,埠範圍 1–65535。", - "ja": "192.168.1.20 または IPv4:port (1 ~ 65535) などの有効な IPv4 アドレスを入力します。", - "ko": "192.168.1.20 또는 IPv4:포트(1~65535)와 같은 유효한 IPv4 주소를 입력하세요.", - "de": "Geben Sie eine gültige IPv4-Adresse ein, z. B. 192.168.1.20 oder IPv4:Port (1–65535).", - "fr": "Saisissez une adresse IPv4 valide telle que 192.168.1.20 ou IPv4:port (1–65535).", - "es": "Ingrese una dirección IPv4 válida como 192.168.1.20 o IPv4:puerto (1–65535).", - "pt-BR": "Insira um endereço IPv4 válido, como 192.168.1.20 ou IPv4:porta (1–65535)." + "请输入 IPv4 或 IPv6 地址;指定端口请用 IPv4:端口或 [IPv6]:端口,端口范围 1–65535。链路本地 IPv6 需带本机网卡作用域,如 fe80::1%14。": { + "zh-Hans": "请输入 IPv4 或 IPv6 地址;指定端口请用 IPv4:端口或 [IPv6]:端口,端口范围 1–65535。链路本地 IPv6 需带本机网卡作用域,如 fe80::1%14。", + "en": "Enter IPv4 or IPv6; specify a port as IPv4:port or [IPv6]:port (1–65535). Link-local IPv6 needs a local interface zone, e.g. fe80::1%14.", + "ru": "Введите IPv4 или IPv6. Порт: IPv4:порт или [IPv6]:порт (1–65535). Для link-local IPv6 укажите зону локального интерфейса, например fe80::1%14.", + "zh-Hant": "請輸入 IPv4 或 IPv6 位址;指定連接埠請用 IPv4:埠或 [IPv6]:埠,範圍 1–65535。鏈路本地 IPv6 需帶本機網卡作用域,如 fe80::1%14。", + "ja": "IPv4 または IPv6 を入力してください。ポートは IPv4:port または [IPv6]:port(1~65535)で指定します。リンクローカル IPv6 には自機のインターフェースゾーンが必要です(例: fe80::1%14)。", + "ko": "IPv4 또는 IPv6를 입력하세요. 포트는 IPv4:포트 또는 [IPv6]:포트(1–65535)로 지정합니다. 링크 로컬 IPv6에는 로컬 인터페이스 영역이 필요합니다(예: fe80::1%14).", + "de": "IPv4 oder IPv6 eingeben; Port als IPv4:Port oder [IPv6]:Port (1–65535). Link-lokales IPv6 benötigt die lokale Schnittstellenzone, z. B. fe80::1%14.", + "fr": "Saisissez IPv4 ou IPv6 ; port : IPv4:port ou [IPv6]:port (1–65535). IPv6 local au lien nécessite la zone de votre interface locale, par exemple fe80::1%14.", + "es": "Ingrese IPv4 o IPv6; puerto: IPv4:puerto o [IPv6]:puerto (1–65535). IPv6 local de enlace requiere la zona de la interfaz local, p. ej. fe80::1%14.", + "pt-BR": "Insira IPv4 ou IPv6; porta: IPv4:porta ou [IPv6]:porta (1–65535). IPv6 local de enlace exige a zona da interface local, por exemplo fe80::1%14." }, "请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。": { "zh-Hans": "请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", @@ -10260,17 +10260,17 @@ "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" + "监听所有 IP 接口": { + "zh-Hans": "监听所有 IP 接口", + "en": "Listen on all IP interfaces", + "ru": "Все IP-интерфейсы", + "zh-Hant": "監聽所有 IP 介面", + "ja": "すべての IP で待ち受け", + "ko": "모든 IP 인터페이스 수신", + "de": "Alle IP-Schnittstellen", + "fr": "Toutes les interfaces IP", + "es": "Todas las interfaces IP", + "pt-BR": "Todas as interfaces IP" }, "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。": { "zh-Hans": "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", @@ -10587,7 +10587,7 @@ "设备正忙,请稍后重试。", "访问密码必须为 6 位英文字母或数字,区分大小写。", "无法连接,请检查对方 IP、程序是否运行,以及防火墙是否放行。", - "请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", + "请输入 IPv4 或 IPv6 地址;指定端口请用 IPv4:端口或 [IPv6]:端口,端口范围 1–65535。链路本地 IPv6 需带本机网卡作用域,如 fe80::1%14。", "请输入 1–65535 之间的整数端口。", "请点击主界面密码旁的修改按钮设置密码。", "IP 连接数量已达上限,请先断开部分连接后重试。", @@ -10625,7 +10625,7 @@ " 台", "设备 / ID", "桌面墙", - "请输入有效的 IPv4 或 IPv4:端口", + "请输入有效的 IPv4 或 IPv6 地址", "正在连接…", "全部设备", "正在读取…", @@ -10776,7 +10776,7 @@ "未连接", "确认断开", "确认执行", - "监听所有 IPv4 接口", + "监听所有 IP 接口", "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。" ], "platforms": { @@ -11174,8 +11174,8 @@ "请输入六位密码:", "请输入密码:", "请输入有效的 9 位设备 ID", - "请输入有效的 IPv4 或 IPv4:端口", - "请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", + "请输入有效的 IPv4 或 IPv6 地址", + "请输入 IPv4 或 IPv6 地址;指定端口请用 IPv4:端口或 [IPv6]:端口,端口范围 1–65535。链路本地 IPv6 需带本机网卡作用域,如 fe80::1%14。", "请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", "请输入连接名称:", "请连接 Wi-Fi 或网线,", @@ -11260,7 +11260,7 @@ "高", "(仅在交互终端显示)", ":同步键鼠到所有已连接设备", - "监听所有 IPv4 接口", + "监听所有 IP 接口", "所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。" ], "android": [