mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-25 06:13:57 +08:00
[feat] add authenticated LAN transport and per-IP listeners
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
#include "lan_auth_limiter.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
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<bool()>& verify) {
|
||||
// Reserve both budgets atomically, then release the lock for slow hashing.
|
||||
std::unique_lock<std::mutex> 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<int>(
|
||||
std::chrono::ceil<std::chrono::seconds>(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<int>(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<std::mutex> 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<int>(std::chrono::ceil<std::chrono::seconds>(
|
||||
value.expires - now)
|
||||
.count());
|
||||
};
|
||||
const int delay = retry(local, source_limit_);
|
||||
if (delay > 0) return delay;
|
||||
++local.count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::lan
|
||||
@@ -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 <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
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<Clock::time_point()>;
|
||||
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<bool()>& verify);
|
||||
|
||||
private:
|
||||
struct Failures {
|
||||
size_t count = 0;
|
||||
size_t pending = 0;
|
||||
Clock::time_point expires;
|
||||
};
|
||||
using Buckets = std::unordered_map<std::string, Failures>;
|
||||
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<std::string, Window> sources_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,656 @@
|
||||
#include "lan_identity.h"
|
||||
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <openssl/x509v3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
|
||||
#include "lan_password.h"
|
||||
#ifdef _WIN32
|
||||
// Winsock must precede windows.h; the networking/ACL headers need its types.
|
||||
// clang-format off
|
||||
#include <winsock2.h>
|
||||
#include <windows.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <Aclapi.h>
|
||||
#include <iphlpapi.h>
|
||||
#include <netioapi.h>
|
||||
#include <sddl.h>
|
||||
// clang-format on
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
#include <net/if_dl.h>
|
||||
#include <net/if_types.h>
|
||||
#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<unsigned char> 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<TOKEN_USER*>(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<DWORD>(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<size_t>(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<unsigned char> 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<unsigned char> 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<TOKEN_USER*>(user.data())->User.Sid;
|
||||
PSID owner = nullptr;
|
||||
PACL acl = nullptr;
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
Require(GetNamedSecurityInfoW(
|
||||
const_cast<wchar_t*>(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<ACCESS_ALLOWED_ACE*>(raw);
|
||||
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
|
||||
EqualSid(const_cast<DWORD*>(&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<TOKEN_USER*>(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<char>(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_t>(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<char>((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<unsigned char*>(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<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
|
||||
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
|
||||
std::array<unsigned char, 32> data{}, tag{};
|
||||
int size = 0, tail = 0;
|
||||
Require(
|
||||
cipher &&
|
||||
EVP_EncryptInit_ex(
|
||||
cipher.get(), EVP_aes_256_gcm(), nullptr,
|
||||
reinterpret_cast<const unsigned char*>(key.data()),
|
||||
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
|
||||
EVP_EncryptUpdate(
|
||||
cipher.get(), nullptr, &size,
|
||||
reinterpret_cast<const unsigned char*>(context.data()),
|
||||
static_cast<int>(context.size())) == 1 &&
|
||||
EVP_EncryptUpdate(
|
||||
cipher.get(), data.data(), &size,
|
||||
reinterpret_cast<const unsigned char*>(password.data()),
|
||||
static_cast<int>(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<const unsigned char*>(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<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> 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<const unsigned char*>(key.data()),
|
||||
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
|
||||
EVP_DecryptUpdate(
|
||||
cipher.get(), nullptr, &size,
|
||||
reinterpret_cast<const unsigned char*>(context.data()),
|
||||
static_cast<int>(context.size())) == 1 &&
|
||||
EVP_DecryptUpdate(
|
||||
cipher.get(),
|
||||
reinterpret_cast<unsigned char*>(plain.value.data()), &size,
|
||||
reinterpret_cast<const unsigned char*>(data.data()),
|
||||
static_cast<int>(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<unsigned char*>(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<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||
EVP_PKEY_Q_keygen(nullptr, nullptr, "EC", "prime256v1"), EVP_PKEY_free);
|
||||
std::unique_ptr<X509, decltype(&X509_free)> 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<const unsigned char*>("CrossDesk LAN"), -1, -1, 0);
|
||||
X509_set_issuer_name(cert.get(), name);
|
||||
for (const auto& extension :
|
||||
{std::pair<int, const char*>{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<char*>(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, decltype(&BIO_free)> 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<std::string, unsigned> 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<Interface> PhysicalInterfaces() {
|
||||
std::vector<Interface> result;
|
||||
#ifdef _WIN32
|
||||
ULONG bytes = 16384;
|
||||
std::vector<unsigned char> buffer(bytes);
|
||||
auto* adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(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<IP_ADAPTER_ADDRESSES*>(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<sockaddr_in*>(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<sockaddr_dl*>(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<sockaddr_in*>(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<unsigned char> data(bytes);
|
||||
Require(RAND_bytes(data.data(), static_cast<int>(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<std::string>(), 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, decltype(&BIO_free)> bio(
|
||||
BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())), BIO_free);
|
||||
std::unique_ptr<X509, decltype(&X509_free)> 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<BIO, decltype(&BIO_free)> key_bio(
|
||||
BIO_new_mem_buf(secret.data(), static_cast<int>(secret.size())),
|
||||
BIO_free);
|
||||
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> 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<std::string, nlohmann::json> 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
|
||||
@@ -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 <openssl/x509.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string, unsigned> ParseEndpoint(const std::string& value,
|
||||
unsigned default_port);
|
||||
bool IsIpv4(const std::string& value);
|
||||
std::vector<Interface> 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<std::string, nlohmann::json> 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
|
||||
@@ -0,0 +1,122 @@
|
||||
#include "lan_password.h"
|
||||
|
||||
#include <openssl/core_names.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/kdf.h>
|
||||
#include <openssl/params.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
|
||||
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<unsigned char>((hi << 4) | lo);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
using Kdf = std::unique_ptr<EVP_KDF, decltype(&EVP_KDF_free)>;
|
||||
using Context = std::unique_ptr<EVP_KDF_CTX, decltype(&EVP_KDF_CTX_free)>;
|
||||
|
||||
std::optional<DevicePasswordRecord> Derive(
|
||||
const std::string& password,
|
||||
const std::array<unsigned char, kSaltBytes>& 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<char*>(password.data()),
|
||||
password.size()),
|
||||
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT,
|
||||
const_cast<unsigned char*>(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<unsigned char, kDigestBytes> 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<DevicePasswordRecord> Hash(const std::string& password) {
|
||||
if (!ValidInput(password)) return std::nullopt;
|
||||
std::array<unsigned char, kSaltBytes> salt{};
|
||||
if (RAND_bytes(salt.data(), salt.size()) != 1) return std::nullopt;
|
||||
return Derive(password, salt);
|
||||
}
|
||||
|
||||
bool IsCurrent(const DevicePasswordRecord& record) {
|
||||
std::array<unsigned char, kSaltBytes> salt{};
|
||||
std::array<unsigned char, kDigestBytes> 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<unsigned char, kSaltBytes> 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
|
||||
@@ -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 <optional>
|
||||
#include <string>
|
||||
|
||||
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<DevicePasswordRecord> 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
|
||||
@@ -0,0 +1,806 @@
|
||||
#include "lan_session_manager.h"
|
||||
|
||||
#include <openssl/crypto.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <thread>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Json = nlohmann::json;
|
||||
template <class T>
|
||||
bool Ready(std::future<T>& 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<const std::string&>();
|
||||
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<Session*>(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<Session*>(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::ListenerIssue> 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<Session>();
|
||||
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<int>(
|
||||
std::chrono::ceil<std::chrono::seconds>(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<std::string>();
|
||||
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<std::string>();
|
||||
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<int>());
|
||||
}
|
||||
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<uint64_t>() ==
|
||||
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<std::string>();
|
||||
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<std::string&>();
|
||||
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<uint64_t>() == 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<std::string>();
|
||||
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<Session>();
|
||||
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<std::string&>());
|
||||
}
|
||||
} 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<std::string> 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<std::string>();
|
||||
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
|
||||
@@ -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 <atomic>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
#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<bool> closing{false}, authorized{false}, ready{false},
|
||||
failed{false};
|
||||
std::atomic<int> failure_code{-1};
|
||||
std::mutex signal_mutex;
|
||||
size_t signal_count = 0, signal_bytes = 0;
|
||||
std::deque<std::string> signals, pending_receive;
|
||||
std::shared_ptr<void> callback_owner;
|
||||
std::function<void()> 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<Session>;
|
||||
struct Prepared {
|
||||
PeerPtr* peer = nullptr;
|
||||
std::shared_ptr<void> callback_owner;
|
||||
std::function<void()> drain_callbacks;
|
||||
};
|
||||
struct Hooks {
|
||||
// Called on the manager to take device snapshots; returned work runs off
|
||||
// UI.
|
||||
std::function<std::function<Prepared()>(const SessionPtr&)> prepare;
|
||||
std::function<void(const SessionPtr&)> attached, connected, closed;
|
||||
std::function<void(const std::string&)> 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<Interface>& 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<ListenerIssue> 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<Interface> interfaces_;
|
||||
std::map<uint64_t, SessionPtr> sessions_;
|
||||
struct AuthJob {
|
||||
SessionPtr session;
|
||||
std::future<DeviceAuthLimiter::Result> result;
|
||||
};
|
||||
struct PrepareJob {
|
||||
SessionPtr session;
|
||||
std::future<Prepared> result;
|
||||
};
|
||||
std::vector<AuthJob> auth_jobs_;
|
||||
std::vector<PrepareJob> prepare_jobs_;
|
||||
std::vector<std::future<void>> cleanup_jobs_;
|
||||
DeviceAuthLimiter auth_limiter_;
|
||||
CredentialWorkLimiter credential_work_limiter_;
|
||||
std::map<std::string, std::chrono::steady_clock::time_point> retry_at_;
|
||||
struct Listener {
|
||||
uint64_t id;
|
||||
unsigned port;
|
||||
};
|
||||
std::map<std::string, Listener> listeners_;
|
||||
std::map<std::string, ListenerIssue> listener_errors_, listener_warnings_;
|
||||
uint64_t epoch_ = 1;
|
||||
bool stopping_ = false, network_drained_ = false;
|
||||
std::chrono::steady_clock::time_point interface_check_{};
|
||||
std::future<std::pair<std::string, nlohmann::json>> reset_job_;
|
||||
std::string new_password_, access_password_;
|
||||
};
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,550 @@
|
||||
#include "lan_transport.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
#include <websocketpp/client.hpp>
|
||||
#include <websocketpp/config/asio.hpp>
|
||||
#include <websocketpp/config/asio_client.hpp>
|
||||
#include <websocketpp/server.hpp>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Server = websocketpp::server<websocketpp::config::asio_tls>;
|
||||
using Client = websocketpp::client<websocketpp::config::asio_tls_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<const char*>(&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<unsigned short>(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<Context> TlsContext() {
|
||||
auto context = std::make_shared<Context>(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<bool> 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<void(const std::string&)> send, ping;
|
||||
std::function<void()> close, graceful_close;
|
||||
};
|
||||
struct Transport::Impl {
|
||||
asio::io_context io;
|
||||
asio::executor_work_guard<asio::io_context::executor_type> work{
|
||||
io.get_executor()};
|
||||
asio::steady_timer timer{io};
|
||||
std::thread thread;
|
||||
std::atomic<bool> stopping{false};
|
||||
std::atomic<uint64_t> next_id{1};
|
||||
std::map<uint64_t, std::shared_ptr<Server>> servers;
|
||||
std::map<uint64_t, std::shared_ptr<Client>> clients;
|
||||
std::map<Hdl, ChannelPtr, std::owner_less<Hdl>> channels;
|
||||
size_t preauth = 0;
|
||||
std::set<uint64_t> retired_listeners;
|
||||
std::mutex event_mutex;
|
||||
std::vector<Event> 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<ChannelPtr> 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 <class Endpoint>
|
||||
void Attach(Endpoint* endpoint, Hdl hdl, const ChannelPtr& c) {
|
||||
channels[hdl] = c;
|
||||
c->state->send = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(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<Channel>(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<Channel>(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<Channel>(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 <class Endpoint>
|
||||
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<Impl>()) {}
|
||||
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<std::promise<Listener>>();
|
||||
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<Server>();
|
||||
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<Channel>();
|
||||
c->id = impl_->next_id++;
|
||||
c->listener = id;
|
||||
c->incoming = true;
|
||||
c->state = std::make_shared<Channel::State>();
|
||||
++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::acceptor_ptr>();
|
||||
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<unsigned short>(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<Channel>();
|
||||
c->id = impl_->next_id++;
|
||||
c->state = std::make_shared<Channel::State>();
|
||||
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<Client>();
|
||||
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::Event> Transport::Poll() {
|
||||
std::lock_guard lock(impl_->event_mutex);
|
||||
std::vector<Event> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<Channel>;
|
||||
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<Event> 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> state;
|
||||
};
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: a9a9ed4230...040114d265
Reference in New Issue
Block a user