diff --git a/apps/desktop/src/network/lan/lan_auth_limiter.cpp b/apps/desktop/src/network/lan/lan_auth_limiter.cpp new file mode 100644 index 00000000..b6f0dff8 --- /dev/null +++ b/apps/desktop/src/network/lan/lan_auth_limiter.cpp @@ -0,0 +1,134 @@ +#include "lan_auth_limiter.h" + +#include +#include +#include +#include + +namespace crossdesk::lan { +DeviceAuthLimiter::DeviceAuthLimiter(Now now, size_t source_failure_limit, + size_t target_failure_limit) + : now_(std::move(now)), + source_failure_limit_(source_failure_limit), + target_failure_limit_(target_failure_limit) { + if (source_failure_limit == 0 || target_failure_limit == 0) + throw std::invalid_argument( + "Authentication failure limits must be positive"); +} + +void DeviceAuthLimiter::Prune(Buckets& buckets, Clock::time_point now) { + for (auto it = buckets.begin(); it != buckets.end();) { + if (it->second.expires <= now && it->second.pending == 0) + it = buckets.erase(it); + else + ++it; + } +} + +DeviceAuthLimiter::Result DeviceAuthLimiter::Verify( + const std::string& source, const std::string& target, + const std::function& verify) { + // Reserve both budgets atomically, then release the lock for slow hashing. + std::unique_lock lock(mutex_); + const auto now = now_(); + if (now >= next_cleanup_) { + Prune(sources_, now); + Prune(targets_, now); + next_cleanup_ = now + std::chrono::minutes(1); + } + // Missing source metadata must not become a per-connection bypass. + const std::string source_key = source.empty() ? "unknown" : source; + auto retry = [now](Buckets& buckets, const std::string& key, size_t limit) { + const auto it = buckets.find(key); + if (it == buckets.end()) return 0; + if (it->second.expires <= now) { + it->second.count = 0; + it->second.expires = now + kWindow; + } + if (it->second.count < limit) + return it->second.count + it->second.pending >= limit ? 1 : 0; + return static_cast( + std::chrono::ceil(it->second.expires - now) + .count()); + }; + const int source_retry = retry(sources_, source_key, source_failure_limit_); + const int target_retry = retry(targets_, target, target_failure_limit_); + const int retry_after = std::max(source_retry, target_retry); + if (retry_after > 0) return {false, retry_after}; + + // Do not evict live failures when flooded with new IDs/IPs. Deny new keys + // until expiry frees capacity, bounding memory without resetting lockouts. + if ((!sources_.count(source_key) && sources_.size() >= kMaxEntries) || + (!targets_.count(target) && targets_.size() >= kMaxEntries)) { + return {false, static_cast(kWindow.count())}; + } + for (auto entry : {std::make_pair(&sources_, source_key), + std::make_pair(&targets_, target)}) { + auto& bucket = + entry.first->try_emplace(entry.second, Failures{0, 0, now + kWindow}) + .first->second; + ++bucket.pending; + } + lock.unlock(); + bool authenticated = false; + std::exception_ptr error; + try { + authenticated = verify(); + } catch (...) { + error = std::current_exception(); + } + lock.lock(); + const auto finished = now_(); + auto finish = [&](Buckets& buckets, const std::string& key, size_t limit) { + auto& bucket = buckets.at(key); + --bucket.pending; + if (bucket.expires <= finished) { + bucket.count = 0; + bucket.expires = finished + kWindow; + } + // The reservation already owns this bucket. Start a full cooldown at the + // threshold; blocked requests do not extend it. + if (!authenticated && ++bucket.count == limit) + bucket.expires = finished + kWindow; + if (bucket.count == 0 && bucket.pending == 0) buckets.erase(key); + }; + finish(sources_, source_key, source_failure_limit_); + finish(targets_, target, target_failure_limit_); + lock.unlock(); + if (error) std::rethrow_exception(error); + return {authenticated, 0}; +} + +int CredentialWorkLimiter::Admit(const std::string& source) { + std::lock_guard lock(mutex_); + const auto now = now_(); + constexpr auto window = std::chrono::minutes(1); + if (now >= next_cleanup_) { + for (auto it = sources_.begin(); it != sources_.end();) { + if (it->second.expires <= now) + it = sources_.erase(it); + else + ++it; + } + next_cleanup_ = now + window; + } + const auto key = source.empty() ? "unknown" : source; + if (!sources_.count(key) && sources_.size() >= DeviceAuthLimiter::kMaxEntries) + return 60; + auto& local = + sources_.try_emplace(key, Window{0, now + window}).first->second; + if (local.expires <= now) local = {0, now + window}; + auto retry = [now](const Window& value, size_t limit) { + return value.count < limit + ? 0 + : static_cast(std::chrono::ceil( + value.expires - now) + .count()); + }; + const int delay = retry(local, source_limit_); + if (delay > 0) return delay; + ++local.count; + return 0; +} + +} // namespace crossdesk::lan diff --git a/apps/desktop/src/network/lan/lan_auth_limiter.h b/apps/desktop/src/network/lan/lan_auth_limiter.h new file mode 100644 index 00000000..fe37592b --- /dev/null +++ b/apps/desktop/src/network/lan/lan_auth_limiter.h @@ -0,0 +1,86 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-24 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LAN_AUTH_LIMITER_H_ +#define _LAN_AUTH_LIMITER_H_ + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk::lan { +// Keep password verification and retry policy aligned with crossdesk-server. +// Failure budgets keyed by transport peer IP and credential target, never a +// connection handle. Use separate instances for independent auth domains. +class DeviceAuthLimiter { + public: + using Clock = std::chrono::steady_clock; + using Now = std::function; + static constexpr size_t kSourceFailures = 20; + static constexpr size_t kTargetFailures = 5; + static constexpr size_t kMaxEntries = 16384; // Per dimension. + static constexpr std::chrono::seconds kWindow{900}; + + struct Result { + bool authenticated = false; + int retry_after = 0; // Positive only when verification was not attempted. + }; + + explicit DeviceAuthLimiter(Now now = Clock::now, + size_t source_failure_limit = kSourceFailures, + size_t target_failure_limit = kTargetFailures); + Result Verify(const std::string& source, const std::string& target, + const std::function& verify); + + private: + struct Failures { + size_t count = 0; + size_t pending = 0; + Clock::time_point expires; + }; + using Buckets = std::unordered_map; + void Prune(Buckets& buckets, Clock::time_point now); + + Now now_; + const size_t source_failure_limit_, target_failure_limit_; + std::mutex mutex_; + Buckets sources_, targets_; + Clock::time_point next_cleanup_{}; +}; + +// Admission before expensive work, including successful authentication, +// registration and password changes. Separate from credential failure budgets. +class CredentialWorkLimiter { + public: + explicit CredentialWorkLimiter( + DeviceAuthLimiter::Now now = DeviceAuthLimiter::Clock::now, + size_t source_limit = 60) + : now_(std::move(now)), source_limit_(source_limit) { + if (!source_limit_) + throw std::invalid_argument("Credential source limit must be positive"); + } + int Admit(const std::string& source); + + private: + struct Window { + size_t count = 0; + DeviceAuthLimiter::Clock::time_point expires{}; + }; + DeviceAuthLimiter::Now now_; + std::mutex mutex_; + const size_t source_limit_; + DeviceAuthLimiter::Clock::time_point next_cleanup_{}; + std::unordered_map sources_; +}; + +} // namespace crossdesk::lan + +#endif diff --git a/apps/desktop/src/network/lan/lan_identity.cpp b/apps/desktop/src/network/lan/lan_identity.cpp new file mode 100644 index 00000000..4409bd69 --- /dev/null +++ b/apps/desktop/src/network/lan/lan_identity.cpp @@ -0,0 +1,656 @@ +#include "lan_identity.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "lan_password.h" +#ifdef _WIN32 +// Winsock must precede windows.h; the networking/ACL headers need its types. +// clang-format off +#include +#include +#include +#include +#include +#include +#include +// clang-format on +#else +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#endif +#endif + +namespace crossdesk::lan { +namespace { +void Require(bool ok, const char* error) { + if (!ok) throw std::runtime_error(error); +} +std::string Hex(const unsigned char* data, size_t size) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result(size * 2, '0'); + for (size_t i = 0; i < size; ++i) { + result[2 * i] = digits[data[i] >> 4]; + result[2 * i + 1] = digits[data[i] & 15]; + } + return result; +} +bool IsHex(const std::string& text, size_t size) { + return text.size() == size && + std::all_of(text.begin(), text.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }); +} +void PrivateWrite(const std::filesystem::path& path, const std::string& text) { + const auto temporary = path.parent_path() / (".write-" + RandomHex(16)); +#ifdef _WIN32 + HANDLE token = nullptr; + Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token), + "Cannot open user token"); + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector data(size); + bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size); + CloseHandle(token); + Require(ok, "Cannot read user token"); + LPWSTR sid = nullptr; + Require(ConvertSidToStringSidW( + reinterpret_cast(data.data())->User.Sid, &sid), + "Cannot read user SID"); + std::wstring acl = L"D:P(A;;FA;;;" + std::wstring(sid) + L")"; + LocalFree(sid); + PSECURITY_DESCRIPTOR descriptor = nullptr; + Require(ConvertStringSecurityDescriptorToSecurityDescriptorW( + acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr), + "Cannot protect LAN file"); + SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE}; + HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security, + CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + LocalFree(descriptor); + Require(file != INVALID_HANDLE_VALUE, "Cannot create LAN file"); + DWORD written = 0; + ok = WriteFile(file, text.data(), static_cast(text.size()), &written, + nullptr) && + written == text.size() && FlushFileBuffers(file); + CloseHandle(file); + if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DeleteFileW(temporary.c_str()); + throw std::runtime_error("Cannot persist LAN file"); + } +#else + int fd = + open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + Require(fd >= 0, "Cannot create private LAN file"); + size_t offset = 0; + while (offset < text.size()) { + const auto count = write(fd, text.data() + offset, text.size() - offset); + if (count <= 0) break; + offset += static_cast(count); + } + bool ok = offset == text.size() && fsync(fd) == 0; + close(fd); + if (!ok || rename(temporary.c_str(), path.c_str()) != 0) { + unlink(temporary.c_str()); + throw std::runtime_error("Cannot persist LAN file"); + } +#endif +} +#ifdef _WIN32 +std::vector CurrentUser() { + HANDLE token = nullptr; + Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token), + "Cannot open user token"); + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector data(size); + bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size); + CloseHandle(token); + Require(ok, "Cannot read user token"); + return data; +} +void CheckPrivateWindowsPath(const std::filesystem::path& path) { + const auto attributes = GetFileAttributesW(path.c_str()); + Require(attributes != INVALID_FILE_ATTRIBUTES && + !(attributes & FILE_ATTRIBUTE_REPARSE_POINT), + "LAN path cannot be a reparse point"); + auto user = CurrentUser(); + auto sid = reinterpret_cast(user.data())->User.Sid; + PSID owner = nullptr; + PACL acl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + Require(GetNamedSecurityInfoW( + const_cast(path.c_str()), SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, + nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS, + "Cannot read LAN file permissions"); + bool ok = owner && EqualSid(owner, sid) && acl && acl->AceCount > 0; + for (DWORD i = 0; ok && i < acl->AceCount; ++i) { + void* raw = nullptr; + if (!GetAce(acl, i, &raw)) { + ok = false; + break; + } + const auto* ace = static_cast(raw); + ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && + EqualSid(const_cast(&ace->SidStart), sid); + } + LocalFree(descriptor); + Require(ok, "LAN files must be accessible only by the current user"); +} +void CreatePrivateDirectory(const std::filesystem::path& path) { + if (!std::filesystem::exists(path)) { + auto user = CurrentUser(); + LPWSTR sid = nullptr; + Require(ConvertSidToStringSidW( + reinterpret_cast(user.data())->User.Sid, &sid), + "Cannot read user SID"); + const auto acl = L"D:P(A;OICI;FA;;;" + std::wstring(sid) + L")"; + LocalFree(sid); + PSECURITY_DESCRIPTOR descriptor = nullptr; + Require(ConvertStringSecurityDescriptorToSecurityDescriptorW( + acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr), + "Cannot protect LAN directory"); + SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE}; + bool ok = CreateDirectoryW(path.c_str(), &security); + LocalFree(descriptor); + Require(ok, "Cannot create LAN directory"); + } + CheckPrivateWindowsPath(path); +} +#endif +std::string Read(const std::filesystem::path& path) { +#ifdef _WIN32 + CheckPrivateWindowsPath(path); +#else + struct stat status{}; + Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) && + status.st_uid == getuid() && (status.st_mode & 0077) == 0, + "LAN file must be owned by this user with mode 0600"); +#endif + Require(std::filesystem::file_size(path) <= 64 * 1024, "LAN file too large"); + std::ifstream input(path, std::ios::binary); + Require(input.good(), "Cannot read LAN file"); + return {std::istreambuf_iterator(input), {}}; +} +std::string BioText(BIO* bio) { + char* data = nullptr; + long size = BIO_get_mem_data(bio, &data); + Require(size > 0, "Cannot encode LAN identity"); + return {data, static_cast(size)}; +} +struct Secret { + std::string value; + ~Secret() { OPENSSL_cleanse(value.data(), value.size()); } +}; +std::string Unhex(const std::string& value, size_t bytes) { + Require(IsHex(value, bytes * 2), "Invalid saved LAN password"); + std::string result(bytes, '\0'); + auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; }; + for (size_t i = 0; i < bytes; ++i) + result[i] = + static_cast((digit(value[2 * i]) << 4) | digit(value[2 * i + 1])); + return result; +} +std::string PasswordKey(const std::filesystem::path& directory, bool create) { + const auto path = directory / "password-key"; + if (create && !std::filesystem::exists(path)) { + Secret key{std::string(32, '\0')}; + Require( + RAND_bytes(reinterpret_cast(key.value.data()), 32) == 1, + "Cannot generate LAN password storage key"); + PrivateWrite(path, key.value); + } + auto key = Read(path); + Require(key.size() == 32, "Invalid LAN password storage key"); + return key; +} +// The independent local key and credential files use owner-only permissions. +// Encryption avoids plaintext in identity.json; it does not protect against +// an attacker who can read both this user's key and credential files. +nlohmann::json EncryptPassword(const std::string& password, + const std::string& key, + const std::string& context) { + const auto nonce = Unhex(RandomHex(12), 12); + std::unique_ptr cipher( + EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free); + std::array data{}, tag{}; + int size = 0, tail = 0; + Require( + cipher && + EVP_EncryptInit_ex( + cipher.get(), EVP_aes_256_gcm(), nullptr, + reinterpret_cast(key.data()), + reinterpret_cast(nonce.data())) == 1 && + EVP_EncryptUpdate( + cipher.get(), nullptr, &size, + reinterpret_cast(context.data()), + static_cast(context.size())) == 1 && + EVP_EncryptUpdate( + cipher.get(), data.data(), &size, + reinterpret_cast(password.data()), + static_cast(password.size())) == 1 && + EVP_EncryptFinal_ex(cipher.get(), data.data() + size, &tail) == 1 && + EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_GET_TAG, 16, + tag.data()) == 1, + "Cannot save LAN password"); + return {{"version", 1}, + {"algorithm", "aes-256-gcm"}, + {"nonce", Hex(reinterpret_cast(nonce.data()), + nonce.size())}, + {"data", Hex(data.data(), size + tail)}, + {"tag", Hex(tag.data(), 16)}}; +} +std::string DecryptPassword(const nlohmann::json& record, + const std::string& key, + const std::string& context) { + Require(record.at("version") == 1 && record.at("algorithm") == "aes-256-gcm", + "Invalid saved LAN password"); + const auto nonce = Unhex(record.at("nonce"), 12); + const auto data = Unhex(record.at("data"), 6); + auto tag = Unhex(record.at("tag"), 16); + Secret plain{std::string(32, '\0')}; + std::unique_ptr cipher( + EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free); + int size = 0, tail = 0; + Require(cipher && + EVP_DecryptInit_ex( + cipher.get(), EVP_aes_256_gcm(), nullptr, + reinterpret_cast(key.data()), + reinterpret_cast(nonce.data())) == 1 && + EVP_DecryptUpdate( + cipher.get(), nullptr, &size, + reinterpret_cast(context.data()), + static_cast(context.size())) == 1 && + EVP_DecryptUpdate( + cipher.get(), + reinterpret_cast(plain.value.data()), &size, + reinterpret_cast(data.data()), + static_cast(data.size())) == 1 && + EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_SET_TAG, 16, + tag.data()) == 1 && + EVP_DecryptFinal_ex( + cipher.get(), + reinterpret_cast(plain.value.data()) + size, + &tail) == 1 && + size + tail == 6, + "Cannot read saved LAN password"); + auto password = plain.value.substr(0, 6); + Require(ValidPassword(password), "Invalid saved LAN password"); + return password; +} +void CreateCertificate(const std::filesystem::path& key_path, + const std::filesystem::path& cert_path) { + std::unique_ptr key( + EVP_PKEY_Q_keygen(nullptr, nullptr, "EC", "prime256v1"), EVP_PKEY_free); + std::unique_ptr cert(X509_new(), X509_free); + Require(key && cert, "Cannot generate LAN identity"); + X509_set_version(cert.get(), 2); + ASN1_INTEGER_set(X509_get_serialNumber(cert.get()), 1); + X509_gmtime_adj(X509_getm_notBefore(cert.get()), -300); + X509_gmtime_adj(X509_getm_notAfter(cert.get()), 10L * 365 * 86400); + X509_set_pubkey(cert.get(), key.get()); + auto* name = X509_get_subject_name(cert.get()); + X509_NAME_add_entry_by_txt( + name, "CN", MBSTRING_ASC, + reinterpret_cast("CrossDesk LAN"), -1, -1, 0); + X509_set_issuer_name(cert.get(), name); + for (const auto& extension : + {std::pair{NID_basic_constraints, "critical,CA:FALSE"}, + {NID_key_usage, "critical,digitalSignature"}, + {NID_ext_key_usage, "serverAuth"}}) { + X509_EXTENSION* ext = X509V3_EXT_conf_nid( + nullptr, nullptr, extension.first, const_cast(extension.second)); + Require(ext != nullptr, "Cannot create certificate extension"); + const int result = X509_add_ext(cert.get(), ext, -1); + X509_EXTENSION_free(ext); + Require(result == 1, "Cannot install certificate extension"); + } + Require(X509_sign(cert.get(), key.get(), EVP_sha256()) > 0, + "Cannot sign LAN certificate"); + std::unique_ptr bio(BIO_new(BIO_s_mem()), BIO_free); + Require(PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0, + nullptr, nullptr) == 1, + "Cannot encode private key"); + PrivateWrite(key_path, BioText(bio.get())); + (void)BIO_reset(bio.get()); + Require(PEM_write_bio_X509(bio.get(), cert.get()) == 1, + "Cannot encode certificate"); + PrivateWrite(cert_path, BioText(bio.get())); +} +} // namespace +unsigned ParsePort(const std::string& value) { + unsigned port = 0; + auto [end, error] = + std::from_chars(value.data(), value.data() + value.size(), port); + Require(error == std::errc{} && end == value.data() + value.size() && + port > 0 && port <= 65535, + "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; +} +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)); + address.resize(colon); + } + Require(IsIpv4(address) && port > 0 && port <= 65535, + "Enter a numeric IPv4 address or IPv4:port"); + return {address, port}; +} +std::vector PhysicalInterfaces() { + std::vector result; +#ifdef _WIN32 + ULONG bytes = 16384; + std::vector buffer(bytes); + auto* adapters = reinterpret_cast(buffer.data()); + ULONG code = + GetAdaptersAddresses(AF_INET, + 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); + } + 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) + continue; + for (auto* u = a->FirstUnicastAddress; u; u = u->Next) { + 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}); + } + } +#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 || + !(a->ifa_flags & IFF_UP) || + (a->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT))) + continue; + bool physical = false; +#ifdef __APPLE__ + for (auto* link = list; link; link = link->ifa_next) + if (link->ifa_addr && link->ifa_addr->sa_family == AF_LINK && + std::string(link->ifa_name) == a->ifa_name) { + const auto* dl = reinterpret_cast(link->ifa_addr); + physical = dl->sdl_type == IFT_ETHER && dl->sdl_alen == 6 && + std::string(a->ifa_name).rfind("en", 0) == 0; + } +#else + physical = std::filesystem::exists(std::filesystem::path("/sys/class/net") / + a->ifa_name / "device"); +#endif + if (!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}); + } + freeifaddrs(list); +#endif + return result; +} +std::string RandomHex(size_t bytes) { + std::vector data(bytes); + Require(RAND_bytes(data.data(), static_cast(bytes)) == 1, + "Random generator failed"); + return Hex(data.data(), bytes); +} +bool ValidPassword(const std::string& password) { + return password.size() == 6 && + password.find_first_not_of( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy" + "z") == std::string::npos; +} +bool VerifyLanCertificate(X509* cert) { + if (!cert || !X509_get0_pubkey(cert)) return false; + return X509_cmp_current_time(X509_get0_notBefore(cert)) < 0 && + X509_cmp_current_time(X509_get0_notAfter(cert)) > 0 && + X509_verify(cert, X509_get0_pubkey(cert)) == 1 && + X509_check_purpose(cert, X509_PURPOSE_SSL_SERVER, 0) == 1 && + (X509_get_key_usage(cert) & KU_DIGITAL_SIGNATURE) != 0; +} +Identity::Identity(std::filesystem::path directory) + : directory_(std::move(directory)) {} +void Identity::Load() { + DevicePassword::CheckSupport(); + std::filesystem::create_directories(directory_.parent_path()); +#ifdef _WIN32 + CreatePrivateDirectory(directory_); +#else + std::filesystem::create_directories(directory_); + struct stat directory_status{}; + Require(lstat(directory_.c_str(), &directory_status) == 0 && + S_ISDIR(directory_status.st_mode) && + directory_status.st_uid == getuid(), + "Invalid LAN directory owner or type"); + Require(chmod(directory_.c_str(), 0700) == 0, "Cannot protect LAN directory"); +#endif + const auto store = directory_ / "identity.json"; + if (!std::filesystem::exists(store)) { + Require(!std::filesystem::exists(private_key()) && + !std::filesystem::exists(certificate()), + "Incomplete LAN identity; manual recovery required"); + CreateCertificate(private_key(), certificate()); + state_ = { + {"version", 1}, {"device_id", RandomHex(16)}, {"verifier", nullptr}}; + Save(state_); + } else + state_ = nlohmann::json::parse(Read(store)); + Require(state_.at("version") == 1 && + IsHex(state_.at("device_id").get(), 32), + "Invalid LAN identity store"); + // Legacy trust records no longer affect connections. Drop them from memory + // and from the next ordinary settings/credential save, without requiring a + // migration write before an outgoing connection can authenticate. + state_.erase("trust"); + // Migrate the single endpoint in memory; the next ordinary save persists it. + // Ports belong to exact IP addresses, including addresses currently offline. + auto& settings = state_["settings"]; + if (settings.is_null()) settings = nlohmann::json::object(); + Require(settings.is_object(), "Invalid LAN settings"); + const auto address = settings.value("address", std::string{}); + Require(address.empty() || IsIpv4(address), "Invalid LAN listener address"); + if (!settings.contains("ports")) { + settings["ports"] = nlohmann::json::object(); + if (!address.empty()) + settings["ports"][address] = settings.contains("port") + ? settings["port"] + : nlohmann::json(52888u); + } + 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, + "Invalid LAN port settings"); + settings["address"] = address; + settings["version"] = 2; + settings.erase("enabled"); + settings.erase("interface"); + settings.erase("port"); + id_ = state_.at("device_id"); + auto pem = Read(certificate()); + std::unique_ptr bio( + BIO_new_mem_buf(pem.data(), static_cast(pem.size())), BIO_free); + std::unique_ptr cert( + PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), X509_free); + Require(VerifyLanCertificate(cert.get()), + "LAN certificate invalid or expired"); + auto secret = Read(private_key()); + std::unique_ptr key_bio( + BIO_new_mem_buf(secret.data(), static_cast(secret.size())), + BIO_free); + std::unique_ptr key( + PEM_read_bio_PrivateKey(key_bio.get(), nullptr, nullptr, nullptr), + EVP_PKEY_free); + Require(key && X509_check_private_key(cert.get(), key.get()) == 1, + "LAN private key does not match certificate"); + OPENSSL_cleanse(secret.data(), secret.size()); + if (!state_["verifier"].is_null()) { + const auto& v = state_["verifier"]; + if (v.at("version") == 1) { + Require(v.at("algorithm") == "scrypt-32768-8-1" && + IsHex(v.at("salt"), 32) && IsHex(v.at("hash"), 64), + "Invalid LAN password verifier"); + } else if (v.at("version") == 2) { + // Legacy PAKE records are read only to recover the locally encrypted + // password for migration. They are never used by the network protocol. + Require(v.at("algorithm") == "spake2p-p256-scrypt-32768-8-1" && + IsHex(v.at("salt"), 32) && IsHex(v.at("w0"), 64) && + IsHex(v.at("L"), 130), + "Invalid LAN password verifier"); + } else { + Require(CurrentVerifier(v), "Invalid LAN password verifier"); + } + } + // Lifetime lockouts are obsolete; retain only expiring in-memory cooldowns. + state_.erase("password_attempts"); +} +void Identity::Save(const nlohmann::json& state) { + PrivateWrite(directory_ / "identity.json", state.dump(2)); +} +std::pair Identity::GeneratePassword() { + constexpr std::string_view alphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + constexpr auto unbiased_limit = 256 - 256 % alphabet.size(); + std::string password; + // Match cloud password generation: unbiased ASCII alphanumeric characters, + // regenerating all-numeric results. Existing numeric passwords remain valid. + do { + password.clear(); + while (password.size() < 6) { + unsigned char byte = 0; + Require(RAND_bytes(&byte, 1) == 1, "Cannot generate LAN password"); + if (byte < unbiased_limit) password += alphabet[byte % alphabet.size()]; + } + } while (password.find_first_not_of("0123456789") == std::string::npos); + return {password, RegisterPassword(password)}; +} +void Identity::SetPassword(const std::string& password, + const nlohmann::json& verifier) { + Require(ValidPassword(password), + "LAN password must contain exactly 6 ASCII letters or digits"); + Require(CurrentVerifier(verifier), "Invalid LAN password verifier"); + Secret key{PasswordKey(directory_, !state_.contains("password"))}; + auto next = state_; + next["verifier"] = verifier; + next["password"] = EncryptPassword( + password, key.value, + "crossdesk.lan.password.v1:" + id_ + ":" + verifier.dump()); + Save(next); + state_ = std::move(next); +} +std::string Identity::ReadPassword() const { + // Verifier-only legacy records require a local password entry to migrate. + if (!state_.contains("password")) return {}; + Secret key{PasswordKey(directory_, false)}; + return DecryptPassword( + state_.at("password"), key.value, + "crossdesk.lan.password.v1:" + id_ + ":" + verifier().dump()); +} +nlohmann::json Identity::RegisterPassword(const std::string& password) { + Require(ValidPassword(password), + "LAN password must contain exactly 6 ASCII letters or digits"); + const auto record = DevicePassword::Hash(password); + Require(record.has_value(), "Cannot derive LAN password verifier"); + return {{"version", 3}, {"salt", record->salt}, {"hash", record->hash}}; +} +bool Identity::CurrentVerifier(const nlohmann::json& verifier) { + return verifier.is_object() && verifier.value("version", 0) == 3 && + verifier.contains("salt") && verifier["salt"].is_string() && + verifier.contains("hash") && verifier["hash"].is_string() && + DevicePassword::IsCurrent({verifier["salt"], verifier["hash"]}); +} +bool Identity::VerifyPassword(const nlohmann::json& verifier, + const std::string& password) { + if (!ValidPassword(password) || !CurrentVerifier(verifier)) return false; + return DevicePassword::Verify( + DevicePasswordRecord{verifier["salt"], verifier["hash"]}, password); +} + +void Identity::SaveSettings(const std::string& address, unsigned port) { + Require(IsIpv4(address) && 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"); + next["settings"]["ports"][address] = port; + Save(next); + state_ = std::move(next); +} +unsigned Identity::PortFor(const std::string& address) const { + return state_.at("settings").at("ports").value(address, 52888u); +} +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(); + Require(std::any_of(interfaces.begin(), interfaces.end(), + [&](const auto& i) { return i.address == address; }), + "Selected LAN interface is unavailable"); + if (state_["settings"].value("address", "") == address) return; + auto next = state_; + next["settings"]["address"] = address; + 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 new file mode 100644 index 00000000..1514185f --- /dev/null +++ b/apps/desktop/src/network/lan/lan_identity.h @@ -0,0 +1,73 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-24 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LAN_IDENTITY_H_ +#define _LAN_IDENTITY_H_ + +#include + +#include +#include +#include +#include + +namespace crossdesk::lan { +struct Interface { + std::string address; +}; +struct Target { + std::string address; + // Learned from the current connection, never pinned. + std::string device_id; + unsigned port = 52888; +}; +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(); +std::string RandomHex(size_t bytes); +bool ValidPassword(const std::string& password); +bool VerifyLanCertificate(X509* cert); + +// Files are separate from cloud settings and thumbnail credentials. A corrupt +// store is an error; it must never be replaced with a newly generated identity. +class Identity { + public: + explicit Identity(std::filesystem::path directory); + void Load(); + static std::pair GeneratePassword(); + void SetPassword(const std::string& password, const nlohmann::json& verifier); + std::string ReadPassword() const; + static nlohmann::json RegisterPassword(const std::string& password); + static bool CurrentVerifier(const nlohmann::json& verifier); + static bool VerifyPassword(const nlohmann::json& verifier, + const std::string& password); + void SaveSettings(const std::string& address, unsigned port); + // An unconfigured address uses the default port; offline addresses keep + // 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; + void SelectAddress(const std::string& address); + const std::string& id() const { return id_; } + std::filesystem::path certificate() const { + return directory_ / "certificate.pem"; + } + std::filesystem::path private_key() const { + return directory_ / "private-key.pem"; + } + const nlohmann::json& verifier() const { return state_.at("verifier"); } + const nlohmann::json& settings() const { return state_.at("settings"); } + + private: + void Save(const nlohmann::json& state); + std::filesystem::path directory_; + std::string id_; + nlohmann::json state_; +}; +} // namespace crossdesk::lan + +#endif diff --git a/apps/desktop/src/network/lan/lan_password.cpp b/apps/desktop/src/network/lan/lan_password.cpp new file mode 100644 index 00000000..e6e724ef --- /dev/null +++ b/apps/desktop/src/network/lan/lan_password.cpp @@ -0,0 +1,122 @@ +#include "lan_password.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace crossdesk::lan { +namespace { +// An application format, with a hex salt in password_salt and hex digest here. +// Only known work factors are accepted, bounding CPU/memory for corrupt data. +constexpr char kPrefix[] = "argon2id$v=19$m=19456,t=2,p=1$"; +constexpr size_t kSaltBytes = 16, kDigestBytes = 32; + +bool ValidInput(const std::string& password) { + return password.size() <= 256 && password.find('\0') == std::string::npos; +} + +std::string Hex(const unsigned char* data, size_t size) { + constexpr char digits[] = "0123456789abcdef"; + std::string result; + result.reserve(size * 2); + for (size_t i = 0; i < size; ++i) { + result += digits[data[i] >> 4]; + result += digits[data[i] & 15]; + } + return result; +} + +bool Unhex(const std::string& text, unsigned char* output, size_t size) { + if (text.size() != size * 2) return false; + auto digit = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + for (size_t i = 0; i < size; ++i) { + const int hi = digit(text[i * 2]), lo = digit(text[i * 2 + 1]); + if (hi < 0 || lo < 0) return false; + output[i] = static_cast((hi << 4) | lo); + } + return true; +} + +using Kdf = std::unique_ptr; +using Context = std::unique_ptr; + +std::optional Derive( + const std::string& password, + const std::array& salt) { + Kdf kdf(EVP_KDF_fetch(nullptr, "ARGON2ID", nullptr), EVP_KDF_free); + if (!kdf) return std::nullopt; + Context context(EVP_KDF_CTX_new(kdf.get()), EVP_KDF_CTX_free); + if (!context) return std::nullopt; + uint32_t memory = 19456, iterations = 2, lanes = 1, threads = 1, + version = 0x13; + OSSL_PARAM params[] = { + OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD, + const_cast(password.data()), + password.size()), + OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT, + const_cast(salt.data()), + salt.size()), + OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_MEMCOST, &memory), + OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ITER, &iterations), + OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, &lanes), + OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads), + OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_VERSION, &version), + OSSL_PARAM_construct_end()}; + std::array digest{}; + if (EVP_KDF_derive(context.get(), digest.data(), digest.size(), params) != 1) + return std::nullopt; + DevicePasswordRecord result{Hex(salt.data(), salt.size()), + kPrefix + Hex(digest.data(), digest.size())}; + OPENSSL_cleanse(digest.data(), digest.size()); + return result; +} +} // namespace + +namespace DevicePassword { +void CheckSupport() { + // Exercise the provider and parameters, not just algorithm discovery. + if (!Hash("startup-check")) + throw std::runtime_error( + "Argon2id or the secure random source is unavailable"); +} + +std::optional Hash(const std::string& password) { + if (!ValidInput(password)) return std::nullopt; + std::array salt{}; + if (RAND_bytes(salt.data(), salt.size()) != 1) return std::nullopt; + return Derive(password, salt); +} + +bool IsCurrent(const DevicePasswordRecord& record) { + std::array salt{}; + std::array digest{}; + return record.hash.rfind(kPrefix, 0) == 0 && + Unhex(record.salt, salt.data(), salt.size()) && + Unhex(record.hash.substr(sizeof(kPrefix) - 1), digest.data(), + digest.size()); +} + +bool Verify(const DevicePasswordRecord& record, const std::string& password) { + if (!ValidInput(password) || !IsCurrent(record)) return false; + std::array salt{}; + Unhex(record.salt, salt.data(), salt.size()); + const auto computed = Derive(password, salt); + return computed && computed->hash.size() == record.hash.size() && + CRYPTO_memcmp(computed->hash.data(), record.hash.data(), + computed->hash.size()) == 0; +} +} // namespace DevicePassword + +} // namespace crossdesk::lan diff --git a/apps/desktop/src/network/lan/lan_password.h b/apps/desktop/src/network/lan/lan_password.h new file mode 100644 index 00000000..d4fe6d17 --- /dev/null +++ b/apps/desktop/src/network/lan/lan_password.h @@ -0,0 +1,33 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-24 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LAN_PASSWORD_H_ +#define _LAN_PASSWORD_H_ + +#include +#include + +namespace crossdesk::lan { +// Keep the password record format aligned with crossdesk-server. +// Stored in identity.json. The hash identifies the algorithm, +// version and work factors; unrecognized formats never fall back to SHA-256. +struct DevicePasswordRecord { + std::string salt; + std::string hash; +}; + +namespace DevicePassword { +void CheckSupport(); +std::optional Hash(const std::string& password); +// LAN accepts only current Argon2id records. Older LAN records are migrated +// locally from the encrypted password before connections are accepted. +bool Verify(const DevicePasswordRecord& record, const std::string& password); +bool IsCurrent(const DevicePasswordRecord& record); +} // namespace DevicePassword + +} // namespace crossdesk::lan + +#endif diff --git a/apps/desktop/src/network/lan/lan_session_manager.cpp b/apps/desktop/src/network/lan/lan_session_manager.cpp new file mode 100644 index 00000000..1decd377 --- /dev/null +++ b/apps/desktop/src/network/lan/lan_session_manager.cpp @@ -0,0 +1,806 @@ +#include "lan_session_manager.h" + +#include + +#include +#include + +namespace crossdesk::lan { +namespace { +using Clock = std::chrono::steady_clock; +using Json = nlohmann::json; +template +bool Ready(std::future& future) { + return future.valid() && + future.wait_for(std::chrono::seconds(0)) == std::future_status::ready; +} +void Check(bool condition) { + if (!condition) throw std::runtime_error("Invalid LAN protocol message"); +} +bool Id(const Json& value) { + if (!value.is_string()) return false; + const auto& s = value.get_ref(); + return s.size() == 32 && std::all_of(s.begin(), s.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }); +} +Json Capabilities() { + return Json::array({"host-only-ipv4", "dtls-srtp-all", "password-auth-v1"}); +} +void Capable(const Json& j) { + const auto& caps = j.at("capabilities"); + Check(caps.is_array() && caps.size() <= 16); + for (const auto& required : Capabilities()) + Check(std::find(caps.begin(), caps.end(), required) != caps.end()); +} +void Wipe(std::string& text) { + if (!text.empty()) OPENSSL_cleanse(text.data(), text.size()); + text.clear(); +} +const char* FailureReason(const Session& s, const char* fallback) { + switch (s.failure_code.load()) { + case MiniRtcDirectInvalidSignal: + return "InvalidSignal"; + case MiniRtcDirectSignalSendFailed: + return "SignalSendFailed"; + case MiniRtcDirectDtlsHandshakeFailed: + return "DtlsHandshakeFailed"; + case MiniRtcDirectRequiredCryptoUnsupported: + return "RequiredCryptoUnsupported"; + case MiniRtcDirectSrtpInstallFailed: + return "SrtpInstallFailed"; + case MiniRtcDirectNetworkPolicyFailed: + return "NetworkPolicyFailed"; + case MiniRtcDirectFingerprintMissing: + return "FingerprintMissing"; + case MiniRtcDirectFingerprintMismatch: + return "FingerprintMismatch"; + case MiniRtcDirectSrtpKeyExportFailed: + return "SrtpKeyExportFailed"; + default: + return fallback; + } +} +} // namespace +int Session::SendSignal(const char* data, size_t size, void* context) { + auto* s = static_cast(context); + if (!s || s->closing || !data || !size || size > 256 * 1024) return -1; + std::lock_guard lock(s->signal_mutex); + if (s->closing || s->signal_count >= 256 || + s->signal_bytes + size > 1024 * 1024) + return -1; + ++s->signal_count; + s->signal_bytes += size; + s->signals.emplace_back(data, size); + return 0; +} +void Session::Error(MiniRtcDirectError error, void* context) { + if (auto* s = static_cast(context)) { + int empty = -1; + s->failure_code.compare_exchange_strong(empty, error); + s->failed = true; + } +} +void Session::Status(ConnectionStatus status) { + if (closing) return; + if (status == ConnectionStatus::Connected) + ready = true; + else if (status == ConnectionStatus::Closed || + status == ConnectionStatus::Failed || + status == ConnectionStatus::Disconnected) + failed = true; +} +SessionManager::SessionManager(std::filesystem::path path, Hooks hooks) + : identity_(std::move(path)), hooks_(std::move(hooks)) { + identity_.Load(); + access_password_ = identity_.ReadPassword(); + new_password_ = access_password_; + if (!Identity::CurrentVerifier(identity_.verifier())) { + if (!access_password_.empty()) { + // 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. + ResetPassword(); + } else { + hooks_.error("LAN password is not ready"); + } + } +} +SessionManager::~SessionManager() { + Stop(); + while (!Quiescent()) { + Tick(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + transport_.Shutdown(); + Wipe(new_password_); + Wipe(access_password_); +} +void SessionManager::Configure(const std::string& address, unsigned port) { + if (stopping_) return; + 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); +} +void SessionManager::ReplaceListener(const std::string& address, unsigned port, + bool save, bool allow_fallback) { + const auto previous = listeners_.find(address); + if (previous != listeners_.end() && previous->second.port == port) return; + const auto next = [&] { + try { + return transport_.Listen(identity_, address, port); + } catch (const std::exception& e) { + const std::string error = e.what(); + if (!allow_fallback || + (error != "LanPortInUse" && error != "LanPortPermissionDenied" && + error != "LanPortCheckFailed")) + throw; + // Keep the OS-assigned socket bound through persistence/publication; + // probing then releasing a free port would introduce an allocation race. + return transport_.Listen(identity_, address, 0); + } + }(); + try { + if (save) identity_.SaveSettings(address, next.port); + } catch (...) { + transport_.StopAccept(next.id); + throw; + } + const auto old = previous == listeners_.end() ? 0 : previous->second.id; + listeners_[address] = {next.id, next.port}; + SetListenerError(address, next.port, ""); + if (old) RetireListener(old); +} +void SessionManager::RetireListener(uint64_t id) { + transport_.StopAccept(id); + // Only cancel unfinished authentication on this retired endpoint. Other + // addresses and sessions that already authenticated keep their channels. + for (auto& [_, s] : sessions_) + if (s->incoming && s->channel->listener == id && + s->phase < Session::AwaitOffer) + Close(s, "ListenerChanged"); +} +void SessionManager::ResetPassword() { + if (stopping_ || reset_job_.valid()) return; + reset_job_ = + std::async(std::launch::async, [current = access_password_]() mutable { + try { + for (;;) { + auto result = Identity::GeneratePassword(); + if (result.first != current) { + Wipe(current); + return result; + } + Wipe(result.first); + } + } catch (...) { + Wipe(current); + throw; + } + }); +} +void SessionManager::SetPassword(std::string password) { + if (!ValidPassword(password)) { + Wipe(password); + throw std::runtime_error( + "LAN password must contain exactly 6 ASCII letters or digits"); + } + if (stopping_ || reset_job_.valid() || + (password == access_password_ && + Identity::CurrentVerifier(identity_.verifier()))) { + Wipe(password); + return; + } + reset_job_ = std::async( + std::launch::async, [password = std::move(password)]() mutable { + try { + auto record = Identity::RegisterPassword(password); + return std::make_pair(std::move(password), std::move(record)); + } catch (...) { + Wipe(password); + throw; + } + }); +} +void SessionManager::SetListenerError(const std::string& address, unsigned port, + const std::string& error) { + if (error.empty()) { + listener_errors_.erase(address); + listener_warnings_.erase(address); + return; + } + const auto previous = listener_errors_.find(address); + if (previous != listener_errors_.end() && previous->second.port == port && + previous->second.error == error) + return; + ListenerIssue issue{address, port, error}; + listener_errors_[address] = issue; + listener_warnings_[address] = std::move(issue); +} +std::string SessionManager::ListenerError(const std::string& address) const { + const auto it = listener_errors_.find(address); + return it == listener_errors_.end() ? "" : it->second.error; +} +std::optional SessionManager::TakeListenerWarning() { + if (listener_warnings_.empty()) return std::nullopt; + auto it = listener_warnings_.begin(); + auto issue = std::move(it->second); + listener_warnings_.erase(it); + return issue; +} +void SessionManager::RefreshListeners() { + if (stopping_ || !Identity::CurrentVerifier(identity_.verifier())) return; + const auto available = [&](const auto& address) { + return std::any_of(interfaces_.begin(), interfaces_.end(), + [&](const auto& i) { return i.address == address; }); + }; + for (auto it = listeners_.begin(); it != listeners_.end();) { + if (available(it->first)) { + ++it; + continue; + } + RetireListener(it->second.id); + it = listeners_.erase(it); + } + for (auto it = listener_errors_.begin(); it != listener_errors_.end();) { + if (available(it->first)) { + ++it; + continue; + } + listener_warnings_.erase(it->first); + it = listener_errors_.erase(it); + } + for (const auto& interface : interfaces_) { + const auto port = identity_.PortFor(interface.address); + const bool initialize = !identity_.HasPortFor(interface.address); + try { + // Persist the first choice even when the default works. Later conflicts + // must not silently change an endpoint users may already have shared. + ReplaceListener(interface.address, port, initialize, initialize); + } catch (const std::exception& e) { + SetListenerError(interface.address, port, e.what()); + } + } +} +std::string SessionManager::TakeNewPassword() { + return std::exchange(new_password_, {}); +} +SessionManager::SessionPtr SessionManager::Connect(const std::string& address, + unsigned port, + std::string password) { + if (stopping_ || sessions_.size() >= 24) + throw std::runtime_error("LAN connection limit reached"); + if (!ValidPassword(password)) + throw std::runtime_error( + "LAN password must contain exactly 6 ASCII letters or digits"); + if (RetryAfter(address, port) > 0) { + Wipe(password); + throw std::runtime_error("AuthenticationRateLimited"); + } + auto s = std::make_shared(); + const auto [ip, endpoint_port] = ParseEndpoint(address, port); + s->target = Target{ip, {}, endpoint_port}; + s->channel = transport_.Connect(s->target); + s->password = std::move(password); + s->epoch = epoch_; + s->key = "lan/" + RandomHex(16); + s->deadline = Clock::now() + std::chrono::seconds(8); + sessions_[s->channel->id] = s; + return s; +} +bool SessionManager::Send(const SessionPtr& s, Json j, bool sequenced) { + j["version"] = 2; + if (sequenced) { + j["session_id"] = s->session_id; + j["seq"] = s->send_seq + 1; + } + if (s->closing || !transport_.Send(s->channel, j.dump())) { + Close(s, "SignalSendFailed"); + return false; + } + if (sequenced) ++s->send_seq; + return true; +} +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 now = Clock::now(); + return it == retry_at_.end() || it->second <= now + ? 0 + : static_cast( + std::chrono::ceil(it->second - now) + .count()); +} +void SessionManager::Fail(const SessionPtr& s, const std::string& error, + int retry_after) { + Json reply = {{"type", "error"}, {"error", error}}; + // Use the same failure codes and retry_after semantics as cloud Join. + if (error == "InvalidCredentials") + reply["error_code"] = "AUTHENTICATION_FAILED"; + if (error == "AuthenticationRateLimited") + reply["error_code"] = "AUTHENTICATION_THROTTLED"; + if (error == "Busy") reply["error_code"] = "CREDENTIAL_SERVICE_BUSY"; + if (retry_after > 0) reply["retry_after"] = retry_after; + if (s->session_id.empty() || + (s->incoming && s->phase < Session::AwaitOffer)) { + reply["request_id"] = s->auth_request; + Send(s, std::move(reply)); + } else { + Send(s, std::move(reply), true); + } + Close(s, error); +} +void SessionManager::Prepare(const SessionPtr& s) { + size_t admitted = 0; + for (auto& [_, other] : sessions_) + if (!other->closing && (other->phase == Session::Preparing || + other->phase >= Session::AwaitOffer)) + ++admitted; + if (admitted >= 8 || prepare_jobs_.size() >= 2) { + Fail(s, "Busy"); + return; + } + s->phase = Session::Preparing; + if (s->incoming) { + s->session_id = RandomHex(16); + s->local_id = identity_.id(); + s->remote_id = RandomHex(16); + } + auto work = hooks_.prepare(s); + prepare_jobs_.push_back( + {s, std::async(std::launch::async, [s, work = std::move(work)] { + if (s->closing) return Prepared{}; + Prepared prepared = work(); + if (!prepared.peer || s->closing) return prepared; + MiniRtcDirectOptions options{}; + options.struct_size = sizeof(options); + options.version = 1; + options.local_id = s->local_id.c_str(); + options.remote_id = s->remote_id.c_str(); + options.session_id = s->session_id.c_str(); + options.local_address = s->channel->local_address.c_str(); + options.remote_address = s->channel->remote_address.c_str(); + options.offerer = !s->incoming; + options.send_signal = Session::SendSignal; + options.on_error = Session::Error; + options.context = s.get(); + if (InitDirectPeer(prepared.peer, &options) != 0 || + (!s->closing && + StartDirectSession(prepared.peer, !s->incoming) != 0)) + s->failed = true; + return prepared; + })}); +} +void SessionManager::Message(const SessionPtr& s, const std::string& text) { + auto j = Json::parse(text); + Check(j.is_object() && j.at("version").is_number_integer() && + j.at("version") == 2 && j.at("type").is_string() && + !j.contains("user_id") && !j.contains("remote_user_id")); + const auto type = j.at("type").get(); + if (type == "error" && (s->session_id.empty() || + (s->incoming && s->phase < Session::AwaitOffer))) { + Check(!j.contains("session_id") && !j.contains("seq") && + j.at("request_id") == s->auth_request); + const auto error = j.at("error").get(); + Check(error.size() <= 80); + if (!s->incoming && + (error == "AuthenticationRateLimited" || error == "Busy") && + j.contains("retry_after")) { + Check(j["retry_after"].is_number_integer() && j["retry_after"] > 0 && + j["retry_after"] <= 900); + const auto now = Clock::now(); + for (auto it = retry_at_.begin(); it != retry_at_.end();) + if (it->second <= now) + it = retry_at_.erase(it); + else + ++it; + if (retry_at_.size() < 64) + retry_at_[s->target.address + ":" + std::to_string(s->target.port)] = + now + std::chrono::seconds(j["retry_after"].get()); + } + Close(s, error); + return; + } + if (s->phase == Session::Preparing && !s->incoming) { + Check(j.at("session_id") == s->session_id && + j.at("seq").is_number_unsigned() && + j.at("seq").get() == + s->receive_seq + s->pending_receive.size() + 1 && + !j.contains("request_id")); + // Identity is already bound by auth_result. Process cancellation without + // waiting for Peer preparation, before the following WSS close can erase + // its reason; earlier queued SDP/candidates never grant authorization. + if (type == "bye" || type == "error") { + const auto reason = type == "bye" ? std::string("RemoteClosed") + : j.at("error").get(); + Check(!reason.empty() && reason.size() <= 80); + Close(s, reason); + return; + } + Check(type == "answer" || type == "candidate"); + Check(s->pending_receive.size() < 128); + s->pending_receive.push_back(text); + return; + } + if (s->phase == Session::AwaitAuth) { + 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); + s->auth_request = j.at("request_id"); + auto& submitted = j["password"].get_ref(); + if (!ValidPassword(submitted)) { + Wipe(submitted); + Fail(s, "InvalidCredentials"); + return; + } + if (auth_jobs_.size() >= 2) { + Wipe(submitted); + Fail(s, "Busy", 1); + return; + } + const auto delay = + credential_work_limiter_.Admit(s->channel->remote_address); + if (delay > 0) { + Wipe(submitted); + Fail(s, "AuthenticationRateLimited", delay); + return; + } + s->phase = Session::Authenticating; + auth_jobs_.push_back( + {s, + std::async(std::launch::async, + [this, password = std::exchange(submitted, {}), + record = identity_.verifier(), + source = s->channel->remote_address, + target = identity_.id()]() mutable { + try { + auto result = auth_limiter_.Verify(source, target, [&] { + return Identity::VerifyPassword(record, password); + }); + Wipe(password); + return result; + } catch (...) { + Wipe(password); + throw; + } + })}); + return; + } + if (s->phase == Session::AwaitAuthResult) { + Check(type == "auth_result" && j.at("request_id") == s->auth_request && + !j.contains("seq")); + if (!j.value("success", false)) { + Close(s, "InvalidCredentials"); + return; + } + Check(Id(j.at("session_id")) && Id(j.at("host_peer_id")) && + Id(j.at("controller_peer_id")) && + j.at("controller_peer_id") != j.at("host_peer_id")); + s->target.device_id = j.at("host_peer_id"); + s->session_id = j.at("session_id"); + s->local_id = j.at("controller_peer_id"); + s->remote_id = j.at("host_peer_id"); + Prepare(s); + return; + } + Check(s->peer && j.at("session_id") == s->session_id && + j.at("seq").is_number_unsigned() && + j.at("seq").get() == s->receive_seq + 1 && + !j.contains("request_id")); + ++s->receive_seq; + if (type == "bye" || type == "error") { + const auto reason = type == "bye" ? std::string("RemoteClosed") + : j.at("error").get(); + Check(!reason.empty() && reason.size() <= 80); + Close(s, reason); + return; + } + if (type == "offer") { + Check(s->incoming && s->phase == Session::AwaitOffer); + s->phase = Session::Negotiating; + s->deadline = Clock::now() + std::chrono::seconds(15); + } else if (type == "answer") { + Check(!s->incoming && s->phase == Session::AwaitAnswer); + s->phase = Session::Negotiating; + } else + Check(type == "candidate" && + (s->phase == Session::AwaitAnswer || + s->phase == Session::Negotiating || s->phase == Session::Connected)); + if (ReceiveDirectSignal(s->peer, text.data(), text.size()) != 0) + Fail(s, "InvalidSignal"); +} +void SessionManager::Dispose(const SessionPtr& s) { + if (!s->peer) return; + auto* peer = std::exchange(s->peer, nullptr); + auto owner = s->callback_owner; + auto drain = s->drain_callbacks; + cleanup_jobs_.push_back( + std::async(std::launch::async, [s, peer, owner, drain]() mutable { + if (drain) drain(); + CloseDirectSession(peer); + DestroyPeer(&peer); + })); +} +void SessionManager::Close(const SessionPtr& s, const std::string& error) { + if (!s || s->closing.exchange(true)) return; + s->authorized = false; + if (error.empty() && !s->session_id.empty() && + s->phase >= Session::AwaitOffer) + transport_.Send(s->channel, Json{{"version", 2}, + {"type", "bye"}, + {"session_id", s->session_id}, + {"seq", ++s->send_seq}} + .dump()); + s->phase = Session::Closing; + s->error = error; + Wipe(s->password); + transport_.Close(s->channel); + hooks_.closed(s); + Dispose(s); + if (!error.empty() && !s->incoming && !stopping_) hooks_.error(error); +} +void SessionManager::Stop() { + if (stopping_) return; + stopping_ = true; + for (const auto& [_, listener] : listeners_) transport_.StopAccept(listener.id); + listeners_.clear(); + listener_errors_.clear(); + listener_warnings_.clear(); + for (auto& [_, s] : sessions_) Close(s); +} +bool SessionManager::Quiescent() const { + return (!stopping_ || network_drained_) && sessions_.empty() && + auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() && + !reset_job_.valid(); +} +std::string SessionManager::PendingWork() const { + return "sessions=" + std::to_string(sessions_.size()) + + ", auth=" + std::to_string(auth_jobs_.size()) + + ", preparing=" + std::to_string(prepare_jobs_.size()) + + ", cleanup=" + std::to_string(cleanup_jobs_.size()) + + ", reset=" + std::to_string(reset_job_.valid()) + + ", network_drained=" + std::to_string(network_drained_); +} +void SessionManager::Tick() { + if (Ready(reset_job_)) { + try { + auto [password, verifier] = reset_job_.get(); + if (!stopping_) { + const bool changed = verifier != identity_.verifier(); + try { + identity_.SetPassword(password, verifier); + } catch (...) { + Wipe(password); + throw; + } + if (changed) ++epoch_; + Wipe(new_password_); + Wipe(access_password_); + access_password_ = password; + new_password_ = std::move(password); + for (auto& [_, s] : sessions_) + if (s->incoming && !s->authorized && s->epoch != epoch_) + Fail(s, "CredentialsChanged"); + interface_check_ = {}; // Start listening as soon as credentials are ready. + } else + Wipe(password); + } catch (const std::exception& e) { + hooks_.error(e.what()); + } + } + for (auto it = auth_jobs_.begin(); it != auth_jobs_.end();) { + if (!Ready(it->result)) { + ++it; + continue; + } + auto s = it->session; + DeviceAuthLimiter::Result result; + bool failed = false; + try { + result = it->result.get(); + } catch (...) { + failed = true; + } + it = auth_jobs_.erase(it); + if (s->closing || stopping_ || s->epoch != epoch_) continue; + if (failed) { + Fail(s, "Busy", 1); + } else if (result.retry_after > 0) { + Fail(s, "AuthenticationRateLimited", result.retry_after); + } else if (!result.authenticated) { + Fail(s, "InvalidCredentials"); + } else { + try { + Prepare(s); + } catch (...) { + Fail(s, "PeerPreparationFailed"); + } + } + } + for (auto it = prepare_jobs_.begin(); it != prepare_jobs_.end();) { + if (!Ready(it->result)) { + ++it; + continue; + } + auto s = it->session; + try { + auto p = it->result.get(); + s->peer = p.peer; + s->callback_owner = std::move(p.callback_owner); + s->drain_callbacks = std::move(p.drain_callbacks); + } catch (...) { + s->failed = true; + } + it = prepare_jobs_.erase(it); + if (s->closing || stopping_ || (s->incoming && s->epoch != epoch_)) { + Dispose(s); + continue; + } + if (!s->peer || s->failed) { + Fail(s, FailureReason(*s, "PeerPreparationFailed")); + continue; + } + hooks_.attached(s); + transport_.Authenticated(s->channel); + if (s->incoming) { + s->phase = Session::AwaitOffer; + s->deadline = Clock::now() + std::chrono::seconds(5); + Send(s, {{"type", "auth_result"}, + {"request_id", s->auth_request}, + {"success", true}, + {"session_id", s->session_id}, + {"host_peer_id", s->local_id}, + {"controller_peer_id", s->remote_id}}); + } else { + s->phase = Session::AwaitAnswer; + s->deadline = Clock::now() + std::chrono::seconds(15); + while (!s->pending_receive.empty() && !s->closing) { + auto text = std::move(s->pending_receive.front()); + s->pending_receive.pop_front(); + try { + Message(s, text); + } catch (...) { + Fail(s, "InvalidSignal"); + } + } + } + } + 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); + if (event.kind != Transport::Event::Open || !event.channel->incoming || + stopping_ || listener == listeners_.end() || + event.channel->listener != listener->second.id) { + transport_.Close(event.channel); + continue; + } + auto s = std::make_shared(); + s->channel = event.channel; + s->incoming = true; + s->key = "lan/" + RandomHex(16); + s->epoch = epoch_; + sessions_[event.channel->id] = s; + it = sessions_.find(event.channel->id); + } + auto s = it->second; + if (s->closing) continue; + if (event.kind == Transport::Event::Open) { + s->deadline = Clock::now() + std::chrono::seconds(10); + s->phase = s->incoming ? Session::AwaitAuth : Session::AwaitAuthResult; + if (!s->incoming) { + s->auth_request = RandomHex(16); + Json auth = {{"type", "auth"}, + {"request_id", s->auth_request}, + {"password", std::exchange(s->password, {})}, + {"capabilities", Capabilities()}}; + Send(s, auth); + Wipe(auth["password"].get_ref()); + } + } else if (event.kind == Transport::Event::Closed) + Close(s, event.payload.empty() ? "ConnectionClosed" : event.payload); + else + try { + Message(s, event.payload); + } catch (...) { + Fail(s, "InvalidSignal"); + } + Wipe(event.payload); + } + const auto now = Clock::now(); + for (auto& [_, s] : sessions_) { + if (s->closing) continue; + if (s->failed) { + Fail(s, FailureReason(*s, "SecureTransportFailed")); + continue; + } + if (s->phase != Session::Connected && now >= s->deadline) { + Fail(s, s->phase == Session::Negotiating ? "IceConnectionTimeout" + : "Timeout"); + continue; + } + std::deque signals; + { + std::lock_guard lock(s->signal_mutex); + signals.swap(s->signals); + } + for (const auto& raw : signals) try { + auto j = Json::parse(raw); + Check(j.at("transmission_id") == s->session_id && + j.at("user_id") == s->local_id && + j.at("remote_user_id") == s->remote_id); + const auto type = j.at("type").get(); + Json out; + if (type == "offer" || type == "answer") + out = {{"type", type}, {"sdp", j.at("sdp")}}; + else { + Check(type == "new_candidate"); + out = {{"type", "candidate"}, {"ufrag", j.at("ufrag")}}; + if (j.at("sdp") == "a=end-of-candidates\r\n") + out["end_of_candidates"] = true; + else + out["candidate"] = j.at("sdp"); + } + if (!Send(s, std::move(out), true)) break; + } catch (...) { + Fail(s, "InvalidLocalSignal"); + break; + } + if (s->ready.exchange(false) && !s->closing && !s->failed) { + if (s->phase != Session::Negotiating || stopping_ || + (s->incoming && s->epoch != epoch_)) { + Fail(s, "CredentialsChanged"); + continue; + } + s->phase = Session::Connected; + s->authorized = true; + hooks_.connected(s); + } + } + if (!stopping_ && now - interface_check_ >= std::chrono::seconds(2)) { + interface_check_ = now; + interfaces_ = PhysicalInterfaces(); + RefreshListeners(); + for (auto& [_, s] : sessions_) + if (s->phase != Session::Connecting && + !s->channel->local_address.empty() && + std::none_of(interfaces_.begin(), interfaces_.end(), + [&](const auto& i) { + return i.address == s->channel->local_address; + })) + Close(s, "InterfaceRemoved"); + } + for (auto it = cleanup_jobs_.begin(); it != cleanup_jobs_.end();) + if (Ready(*it)) { + try { + it->get(); + } catch (...) { + } + it = cleanup_jobs_.erase(it); + } else + ++it; + for (auto it = sessions_.begin(); it != sessions_.end();) { + const auto& s = it->second; + const bool pending = + std::any_of(auth_jobs_.begin(), auth_jobs_.end(), + [&](const auto& j) { return j.session == s; }) || + std::any_of(prepare_jobs_.begin(), prepare_jobs_.end(), + [&](const auto& j) { return j.session == s; }); + if (s->closing && !pending) { + s->phase = Session::Closed; + it = sessions_.erase(it); + } else + ++it; + } + if (stopping_ && !network_drained_ && sessions_.empty() && + auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() && + !reset_job_.valid()) { + transport_.Shutdown(); + network_drained_ = true; + } +} +} // namespace crossdesk::lan diff --git a/apps/desktop/src/network/lan/lan_session_manager.h b/apps/desktop/src/network/lan/lan_session_manager.h new file mode 100644 index 00000000..ddea4116 --- /dev/null +++ b/apps/desktop/src/network/lan/lan_session_manager.h @@ -0,0 +1,151 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-24 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LAN_SESSION_MANAGER_H_ +#define _LAN_SESSION_MANAGER_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "lan_auth_limiter.h" +#include "lan_transport.h" +#include "minirtc.h" + +namespace crossdesk::lan { +struct Session { + enum Phase { + Connecting, + AwaitAuth, + Authenticating, + Preparing, + AwaitAuthResult, + AwaitOffer, + AwaitAnswer, + Negotiating, + Connected, + Closing, + Closed + } phase = Connecting; + Transport::ChannelPtr channel; + Target target; + std::string key, session_id, local_id, remote_id, auth_request; + std::string password, error; + bool incoming = false; + uint64_t epoch = 0, receive_seq = 0, send_seq = 0; + std::chrono::steady_clock::time_point deadline; + PeerPtr* peer = nullptr; // Only published/cleared by the manager. + std::atomic closing{false}, authorized{false}, ready{false}, + failed{false}; + std::atomic failure_code{-1}; + std::mutex signal_mutex; + size_t signal_count = 0, signal_bytes = 0; + std::deque signals, pending_receive; + std::shared_ptr callback_owner; + std::function drain_callbacks; + static int SendSignal(const char* data, size_t size, void* context); + static void Error(MiniRtcDirectError error, void* context); + void Status(ConnectionStatus status); +}; + +// All public methods run on the GUI manager thread. Only Session's bounded +// callback entry points are callable by RTC workers. +class SessionManager { + public: + using SessionPtr = std::shared_ptr; + struct Prepared { + PeerPtr* peer = nullptr; + std::shared_ptr callback_owner; + std::function drain_callbacks; + }; + struct Hooks { + // Called on the manager to take device snapshots; returned work runs off + // UI. + std::function(const SessionPtr&)> prepare; + std::function attached, connected, closed; + std::function error; + }; + SessionManager(std::filesystem::path directory, Hooks hooks); + ~SessionManager(); + Identity& identity() { return identity_; } + // Refreshed by Tick; UI and listener maintenance share the same snapshot. + const std::vector& interfaces() const { return interfaces_; } + void Configure(const std::string& address, unsigned port); + bool password_pending() const { return reset_job_.valid(); } + void ResetPassword(); + void SetPassword(std::string password); + bool listening() const { return !listeners_.empty(); } + struct ListenerIssue { + std::string address; + unsigned port; + std::string error; + }; + std::string ListenerError(const std::string& address) const; + std::optional TakeListenerWarning(); + std::string TakeNewPassword(); + int RetryAfter(const std::string& address, unsigned port) const; + SessionPtr Connect(const std::string& address, unsigned port, + std::string password); + void Close(const SessionPtr& session, const std::string& error = {}); + void Tick(); + void Stop(); + bool Quiescent() const; + std::string PendingWork() const; + + private: + void Prepare(const SessionPtr& session); + void Message(const SessionPtr& session, const std::string& text); + bool Send(const SessionPtr& session, nlohmann::json message, + bool sequenced = false); + void Dispose(const SessionPtr& session); + void Fail(const SessionPtr& session, const std::string& error, + int retry_after = 0); + void RefreshListeners(); + void ReplaceListener(const std::string& address, unsigned port, bool save, + bool allow_fallback = false); + void RetireListener(uint64_t id); + void SetListenerError(const std::string& address, unsigned port, + const std::string& error); + Identity identity_; + Hooks hooks_; + Transport transport_; + std::vector interfaces_; + std::map sessions_; + struct AuthJob { + SessionPtr session; + std::future result; + }; + struct PrepareJob { + SessionPtr session; + std::future result; + }; + std::vector auth_jobs_; + std::vector prepare_jobs_; + std::vector> cleanup_jobs_; + DeviceAuthLimiter auth_limiter_; + CredentialWorkLimiter credential_work_limiter_; + std::map retry_at_; + struct Listener { + uint64_t id; + unsigned port; + }; + std::map listeners_; + std::map listener_errors_, listener_warnings_; + uint64_t epoch_ = 1; + bool stopping_ = false, network_drained_ = false; + std::chrono::steady_clock::time_point interface_check_{}; + std::future> reset_job_; + std::string new_password_, access_password_; +}; +} // namespace crossdesk::lan + +#endif diff --git a/apps/desktop/src/network/lan/lan_transport.cpp b/apps/desktop/src/network/lan/lan_transport.cpp new file mode 100644 index 00000000..f29c6081 --- /dev/null +++ b/apps/desktop/src/network/lan/lan_transport.cpp @@ -0,0 +1,550 @@ +#include "lan_transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk::lan { +namespace { +using Clock = std::chrono::steady_clock; +using Server = websocketpp::server; +using Client = websocketpp::client; +using Hdl = websocketpp::connection_hdl; +using Error = websocketpp::lib::error_code; +using Context = websocketpp::lib::asio::ssl::context; +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 (!IsIpv4(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 error; +#ifdef _WIN32 + (void)automatic_port; + acceptor.set_option(asio::socket_base::reuse_address(false), error); + if (error) return error; + const BOOL exclusive = TRUE; + if (::setsockopt(acceptor.native_handle(), SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + reinterpret_cast(&exclusive), + sizeof(exclusive)) == SOCKET_ERROR) + return Error(WSAGetLastError(), asio::error::get_system_category()); +#else + // Fixed ports may rebind after TIME_WAIT. OS-assigned ports need no reuse; + // exclusive allocation also avoids wildcard listener overlap on BSD. + acceptor.set_option(asio::socket_base::reuse_address(!automatic_port), error); +#endif + return error; +} +void CheckListenError(const Error& error) { + if (!error) return; + if (error == asio::error::address_in_use) + throw std::runtime_error("LanPortInUse"); + if (error == asio::error::access_denied) + throw std::runtime_error("LanPortPermissionDenied"); + throw std::runtime_error("Cannot listen on LAN port: " + error.message()); +} +void CheckExistingListener(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 + // established sessions as occupied ports. No application data is sent. + asio::io_context io; + asio::ip::tcp::socket socket(io); + asio::steady_timer deadline(io, std::chrono::milliseconds(200)); + Error result; + bool timed_out = false; + socket.async_connect( + {asio::ip::make_address_v4(address), static_cast(port)}, + [&](const Error& error) { + result = error; + deadline.cancel(); + }); + deadline.async_wait([&](const Error& error) { + if (error) return; + timed_out = true; + Error ignored; + socket.close(ignored); + }); + io.run(); + if (timed_out) throw std::runtime_error("LanPortCheckFailed"); + if (!result) throw std::runtime_error("LanPortInUse"); + if (result != asio::error::connection_refused) CheckListenError(result); +#else + (void)address; + (void)port; +#endif +} +std::shared_ptr TlsContext() { + auto context = std::make_shared(Context::tls); + if (SSL_CTX_set_min_proto_version(context->native_handle(), TLS1_3_VERSION) != + 1 || + SSL_CTX_set_max_early_data(context->native_handle(), 0) != 1) + throw std::runtime_error("TLS 1.3 is required for LAN connections"); + SSL_CTX_set_options(context->native_handle(), SSL_OP_NO_COMPRESSION); + SSL_CTX_set_session_cache_mode(context->native_handle(), SSL_SESS_CACHE_OFF); + return context; +} +} // namespace +struct Transport::Channel::State { + std::atomic closing{false}; + std::mutex budget_mutex; + size_t sent_count = 0, sent_bytes = 0, received_count = 0, received_bytes = 0; + bool authenticated = false, opened = false, terminal = false; + Clock::time_point created = Clock::now(), last_ping = Clock::now(), + ping_sent{}, ping_window = Clock::now(); + unsigned inbound_pings = 0; + std::string pending_ping, error; + std::function send, ping; + std::function close, graceful_close; +}; +struct Transport::Impl { + asio::io_context io; + asio::executor_work_guard work{ + io.get_executor()}; + asio::steady_timer timer{io}; + std::thread thread; + std::atomic stopping{false}; + std::atomic next_id{1}; + std::map> servers; + std::map> clients; + std::map> channels; + size_t preauth = 0; + std::set retired_listeners; + std::mutex event_mutex; + std::vector events; + Impl() { + Tick(); + thread = std::thread([this] { io.run(); }); + } + bool AdmissionBlocked() { + std::lock_guard lock(event_mutex); + // Keep closed-but-not-yet-consumed channel events in the admission budget. + return events.size() >= 64; + } + void Emit(Event::Kind kind, const ChannelPtr& c, std::string payload = {}) { + std::lock_guard lock(event_mutex); + events.push_back({kind, c, std::move(payload)}); + } + void Finish(const ChannelPtr& c) { + if (c->state->terminal) return; + c->state->closing = true; + c->state->terminal = true; + if (c->incoming && !c->state->authenticated) --preauth; + Emit(Event::Closed, c, c->state->error); + } + void Abort(const ChannelPtr& c) { + if (c->state->terminal) return; + c->state->closing = true; + if (c->state->close) c->state->close(); + } + void Tick() { + timer.expires_after(std::chrono::seconds(1)); + timer.async_wait([this](const Error& error) { + if (error || stopping) return; + const auto now = Clock::now(); + std::vector expired; + for (const auto& [_, c] : channels) { + auto& s = *c->state; + if (s.terminal || s.closing) continue; + if ((!s.opened && now - s.created >= std::chrono::seconds(8)) || + (!s.pending_ping.empty() && + now - s.ping_sent >= std::chrono::seconds(15))) { + s.error = s.opened ? "HeartbeatTimeout" : "ConnectionTimeout"; + expired.push_back(c); + continue; + } + if (s.opened && s.pending_ping.empty() && + now - s.last_ping >= std::chrono::seconds(5)) { + s.pending_ping = RandomHex(8); + s.last_ping = s.ping_sent = now; + s.ping(s.pending_ping); + } + } + for (auto& c : expired) Abort(c); + for (auto it = retired_listeners.begin(); + it != retired_listeners.end();) { + const auto id = *it; + if (std::none_of(channels.begin(), channels.end(), + [id](const auto& item) { + return item.second->listener == id; + })) { + servers.erase(id); + it = retired_listeners.erase(it); + } else + ++it; + } + Tick(); + }); + } + template + void Attach(Endpoint* endpoint, Hdl hdl, const ChannelPtr& c) { + channels[hdl] = c; + c->state->send = [this, endpoint, hdl, cweak = std::weak_ptr(c)]( + const std::string& text) { + Error e; + endpoint->send(hdl, text, websocketpp::frame::opcode::text, e); + if (e) + if (auto c = cweak.lock()) Abort(c); + }; + c->state->ping = [this, endpoint, hdl, cweak = std::weak_ptr(c)]( + const std::string& nonce) { + Error e; + endpoint->ping(hdl, nonce, e); + if (e) + if (auto c = cweak.lock()) Abort(c); + }; + c->state->graceful_close = [this, endpoint, hdl, + cweak = std::weak_ptr(c)] { + Error error; + endpoint->close(hdl, websocketpp::close::status::going_away, "", error); + if (error) + if (auto c = cweak.lock()) Abort(c); + }; + c->state->close = [this, endpoint, hdl, cweak = std::weak_ptr(c)] { + Error e; + auto connection = endpoint->get_con_from_hdl(hdl, e); + if (!e && connection) { + connection->get_raw_socket().cancel(e); + connection->get_raw_socket().shutdown( + asio::ip::tcp::socket::shutdown_both, e); + connection->get_raw_socket().close(e); + } + if (auto c = cweak.lock()) Finish(c); + }; + } + template + void Configure(Endpoint* endpoint) { + endpoint->clear_access_channels(websocketpp::log::alevel::all); + endpoint->clear_error_channels(websocketpp::log::elevel::all); + endpoint->init_asio(&io); + endpoint->set_max_message_size(256 * 1024); + endpoint->set_max_http_body_size(0); + endpoint->set_open_handshake_timeout(8000); + endpoint->set_close_handshake_timeout(1000); + endpoint->set_open_handler([this, endpoint](Hdl hdl) { + auto it = channels.find(hdl); + if (it == channels.end()) return; + auto c = it->second; + if (c->state->closing) { + Abort(c); + return; + } + Error e; + auto connection = endpoint->get_con_from_hdl(hdl, e); + if (e) { + Abort(c); + return; + } + const auto local = connection->get_raw_socket().local_endpoint(e); + if (e) { + Abort(c); + return; + } + const auto remote = connection->get_raw_socket().remote_endpoint(e); + if (e) { + 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 = PhysicalInterfaces(); + if (!IsIpv4(c->remote_address) || + std::none_of( + interfaces.begin(), interfaces.end(), + [&](const auto& i) { return i.address == c->local_address; })) { + c->state->error = "UnsupportedNetworkPath"; + Abort(c); + return; + } + const auto protocol = + c->incoming + ? connection->get_subprotocol() + : connection->get_response_header("Sec-WebSocket-Protocol"); + if (protocol != kProtocol) { + c->state->error = "IncompatibleProtocol"; + Abort(c); + return; + } + c->state->opened = true; + Emit(Event::Open, c); + }); + auto closed = [this, endpoint](Hdl hdl) { + auto it = channels.find(hdl); + if (it != channels.end()) { + auto& state = *it->second->state; + if (state.error.empty() && !state.opened && !state.closing) { + Error error; + auto connection = endpoint->get_con_from_hdl(hdl, error); + state.error = + !error && connection + ? "ConnectionFailed: " + connection->get_ec().message() + : "ConnectionFailed"; + } + const auto id = it->second->id; + const bool incoming = it->second->incoming; + Finish(it->second); + channels.erase(it); + if (!incoming) asio::post(io, [this, id] { clients.erase(id); }); + } + }; + endpoint->set_close_handler(closed); + endpoint->set_fail_handler(closed); + endpoint->set_message_handler( + [this](Hdl hdl, typename Endpoint::message_ptr message) { + auto it = channels.find(hdl); + if (it == channels.end()) return; + auto c = it->second; + auto& s = *c->state; + if (s.closing) return; + const auto& text = message->get_payload(); + if (message->get_opcode() != websocketpp::frame::opcode::text || + text.empty() || text.size() > 256 * 1024 || + ++s.received_count > 256 || + (s.received_bytes += text.size()) > 1024 * 1024) { + Abort(c); + return; + } + Emit(Event::Message, c, text); + }); + endpoint->set_ping_handler([this](Hdl hdl, std::string nonce) { + auto it = channels.find(hdl); + if (it == channels.end()) return false; + auto c = it->second; + auto& s = *c->state; + auto now = Clock::now(); + if (now - s.ping_window >= std::chrono::seconds(5)) { + s.ping_window = now; + s.inbound_pings = 0; + } + if (s.closing || nonce.size() != 16 || ++s.inbound_pings > 2) { + Abort(c); + return false; + } + return true; + }); + endpoint->set_pong_handler([this](Hdl hdl, std::string nonce) { + auto it = channels.find(hdl); + if (it == channels.end()) return; + auto& s = *it->second->state; + if (!s.pending_ping.empty() && nonce == s.pending_ping) + s.pending_ping.clear(); + }); + } +}; +Transport::Transport() : impl_(std::make_unique()) {} +Transport::~Transport() { Shutdown(); } +Transport::Listener Transport::Listen(const Identity& identity, + const std::string& address, + unsigned port) { + ValidateListener(address, port); + auto context = TlsContext(); + context->use_certificate_chain_file(identity.certificate().string()); + context->use_private_key_file(identity.private_key().string(), Context::pem); + auto promise = std::make_shared>(); + auto future = promise->get_future(); + asio::post(impl_->io, [this, address, port, context, promise] { + try { + if (port) CheckExistingListener(address, port); + auto server = std::make_shared(); + impl_->Configure(server.get()); + const uint64_t id = impl_->next_id++; + server->set_tls_init_handler([context](Hdl) { return context; }); + server->set_tcp_pre_init_handler( + [this, endpoint = server.get(), id](Hdl hdl) { + Error error; + auto connection = endpoint->get_con_from_hdl(hdl, error); + if (error) return; + if (impl_->stopping || impl_->retired_listeners.count(id) || + impl_->preauth >= 16 || impl_->AdmissionBlocked() || + impl_->channels.size() >= 24) { + connection->get_raw_socket().close(error); + return; + } + auto c = std::make_shared(); + c->id = impl_->next_id++; + c->listener = id; + c->incoming = true; + c->state = std::make_shared(); + ++impl_->preauth; + impl_->Attach(endpoint, hdl, c); + }); + server->set_validate_handler([endpoint = server.get()](Hdl hdl) { + auto c = endpoint->get_con_from_hdl(hdl); + if (c->get_resource() != kPath || !c->get_request_body().empty() || + !c->get_request_header("Transfer-Encoding").empty() || + (!c->get_request_header("Content-Length").empty() && + c->get_request_header("Content-Length") != "0")) + return false; + const auto& protocols = c->get_requested_subprotocols(); + if (protocols.size() != 1 || protocols[0] != kProtocol) return false; + c->select_subprotocol(kProtocol); + return true; + }); + auto bound_acceptor = std::make_shared(); + server->set_tcp_pre_bind_handler( + [port, bound_acceptor](Server::acceptor_ptr acceptor) { + *bound_acceptor = acceptor; + return ConfigureAcceptor(*acceptor, port == 0); + }); + Error listen_error; + server->listen( + asio::ip::tcp::endpoint(asio::ip::make_address_v4(address), + static_cast(port)), + listen_error); + CheckListenError(listen_error); + const auto actual_port = server->get_local_endpoint(listen_error).port(); + CheckListenError(listen_error); +#ifndef _WIN32 + if (!port) { + // Allocation is already exclusive. Enable TIME_WAIT reuse before any + // accepted socket inherits options so the saved port can restart too. + (*bound_acceptor)->set_option(asio::socket_base::reuse_address(true), + listen_error); + CheckListenError(listen_error); + } +#endif + server->set_tcp_pre_bind_handler({}); + server->start_accept(); + impl_->servers[id] = server; + promise->set_value({id, actual_port}); + } catch (...) { + promise->set_exception(std::current_exception()); + } + }); + return future.get(); +} +void Transport::StopAccept(uint64_t listener) { + asio::post(impl_->io, [this, listener] { + auto it = impl_->servers.find(listener); + if (it != impl_->servers.end()) { + Error e; + it->second->stop_listening(e); + impl_->retired_listeners.insert(listener); + for (auto& [_, c] : impl_->channels) + if (c->listener == listener && !c->state->authenticated) { + c->state->error = "ListenerChanged"; + impl_->Abort(c); + } + } + }); +} +Transport::ChannelPtr Transport::Connect(const Target& target) { + if (!IsIpv4(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++; + c->state = std::make_shared(); + asio::post(impl_->io, [this, c, target] { + try { + if (impl_->stopping || impl_->channels.size() >= 24 || + impl_->AdmissionBlocked()) { + impl_->Finish(c); + return; + } + auto client = std::make_shared(); + impl_->Configure(client.get()); + auto context = TlsContext(); + context->set_verify_mode(asio::ssl::verify_peer); + context->set_verify_callback( + [c](bool, asio::ssl::verify_context& verification) { + X509_STORE_CTX* store = verification.native_handle(); + auto* certificate = X509_STORE_CTX_get_current_cert(store); + // Self-signed LAN certificates establish the encrypted channel. + // Password verification authorizes access at the receiving host; + // it does not authenticate this certificate's device identity. + const bool valid = X509_STORE_CTX_get_error_depth(store) == 0 && + VerifyLanCertificate(certificate); + if (!valid) c->state->error = "InvalidCertificate"; + return valid; + }); + 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, + error); + if (error) { + impl_->Finish(c); + return; + } + connection->add_subprotocol(kProtocol); + impl_->Attach(client.get(), connection->get_handle(), c); + impl_->clients[c->id] = client; + client->connect(connection); + } catch (...) { + impl_->Abort(c); + impl_->Finish(c); + } + }); + return c; +} +bool Transport::Send(const ChannelPtr& c, std::string message) { + if (!c || impl_->stopping || c->state->closing || message.empty() || + message.size() > 256 * 1024) + return false; + { + std::lock_guard lock(c->state->budget_mutex); + if (c->state->sent_count >= 256 || + c->state->sent_bytes + message.size() > 1024 * 1024) + return false; + ++c->state->sent_count; + c->state->sent_bytes += message.size(); + } + asio::post(impl_->io, [c, message = std::move(message)] { + if (!c->state->terminal && c->state->send) c->state->send(message); + }); + return true; +} +void Transport::Authenticated(const ChannelPtr& c) { + asio::post(impl_->io, [this, c] { + if (c->incoming && !c->state->terminal && !c->state->authenticated) { + c->state->authenticated = true; + --impl_->preauth; + } + }); +} +void Transport::Close(const ChannelPtr& c) { + if (!c) return; + c->state->closing = true; + asio::post(impl_->io, [this, c] { + if (c->state->opened && !c->state->terminal && c->state->graceful_close) + c->state->graceful_close(); + else + impl_->Abort(c); + }); +} +std::vector Transport::Poll() { + std::lock_guard lock(impl_->event_mutex); + std::vector result; + result.swap(impl_->events); + return result; +} +void Transport::Shutdown() { + if (!impl_ || impl_->stopping.exchange(true)) return; + asio::post(impl_->io, [this] { + Error e; + impl_->timer.cancel(); + for (auto& [_, server] : impl_->servers) server->stop_listening(e); + for (auto& [_, c] : impl_->channels) impl_->Abort(c); + impl_->work.reset(); + }); + if (impl_->thread.joinable()) impl_->thread.join(); +} +} // namespace crossdesk::lan diff --git a/apps/desktop/src/network/lan/lan_transport.h b/apps/desktop/src/network/lan/lan_transport.h new file mode 100644 index 00000000..4fba23f3 --- /dev/null +++ b/apps/desktop/src/network/lan/lan_transport.h @@ -0,0 +1,65 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-24 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LAN_TRANSPORT_H_ +#define _LAN_TRANSPORT_H_ + +#include +#include +#include +#include + +#include "lan_identity.h" + +namespace crossdesk::lan { +// One network thread; callbacks enqueue bounded value events for the UI-owned +// session manager. No network callback calls MiniRTC or destroys a Peer. +class Transport { + public: + struct Channel; + using ChannelPtr = std::shared_ptr; + struct Event { + enum Kind { Open, Message, Closed } kind; + ChannelPtr channel; + std::string payload; + }; + Transport(); + ~Transport(); + Transport(const Transport&) = delete; + Transport& operator=(const Transport&) = delete; + struct Listener { + uint64_t id; + unsigned port; + }; + // Port zero lets the OS allocate and hold a free port on this address. + Listener Listen(const Identity& identity, const std::string& address, + unsigned port); + void StopAccept(uint64_t listener); + ChannelPtr Connect(const Target& target); + bool Send(const ChannelPtr& channel, std::string message); + void Authenticated(const ChannelPtr& channel); + void Close(const ChannelPtr& channel); + std::vector Poll(); + void Shutdown(); + struct Channel { + uint64_t id = 0, listener = 0; + bool incoming = false; + // Published by Open, immutable thereafter. + std::string local_address, remote_address; + + private: + friend class Transport; + struct State; + std::shared_ptr state; + }; + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace crossdesk::lan + +#endif diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 86e5298a..0a8a39ad 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -563,7 +563,9 @@ function setup_targets() -- version and use fmt's supported runtime-parser fallback here. add_defines("FMT_CONSTEVAL=") add_packages("slint", {public = true}) - add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json") + add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json", "asio", "websocketpp") + add_defines("ASIO_STANDALONE") + add_files("apps/desktop/src/network/lan/*.cpp") if not is_config("CROSSDESK_PORTABLE", true) then add_defines("CROSSDESK_HAS_UPDATER=1", {public = true}) add_deps("updater_core") @@ -599,7 +601,7 @@ function setup_targets() add_deps("windows_unattended_config") add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp") add_links("opengl32") - add_syslinks("Comctl32", {public = true}) + add_syslinks("Comctl32", "Iphlpapi", "Ws2_32", "Advapi32", {public = true}) add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp", "apps/desktop/src/platform/windows/gui/slint_backend.cpp", "apps/desktop/src/platform/windows/gui/window_icons.cpp", diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index a9a9ed42..040114d2 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit a9a9ed42300761c3a24cbf5746f92ff1104d0424 +Subproject commit 040114d26564002bd10dbaa1e80951531c3faea3