mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-25 14:47:00 +08:00
551 lines
20 KiB
C++
551 lines
20 KiB
C++
#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
|