diff --git a/CMakeLists.txt b/CMakeLists.txt index 20054c8a..2bd68e5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,7 +78,7 @@ if (${CANDY_STATIC_POCO}) set(ENABLE_ACTIVERECORD_COMPILER OFF CACHE BOOL "" FORCE) set(ENABLE_ZIP OFF CACHE BOOL "" FORCE) set(ENABLE_JWT OFF CACHE BOOL "" FORCE) - Fetch(poco "https://github.com/pocoproject/poco.git" "poco-1.13.3-release") + Fetch(poco "https://github.com/pocoproject/poco.git" "poco-1.14.2-release") else() find_package(Poco REQUIRED COMPONENTS Foundation XML JSON Net NetSSL Util) endif() @@ -86,6 +86,15 @@ endif() set(THREADS_PREFER_PTHREAD_FLAG ON) find_package(Threads REQUIRED) +# 用户态协议栈(netstack + lwIP)编译开关。默认关闭:不编译任何 netstack 代码、 +# 不拉取 lwIP。线上发行版打包(部分要求构建时无网络)保持关闭即可零影响。 +# GitHub Release 的纯静态二进制可用 -DCANDY_NETSTACK=ON 开启此功能。 +option(CANDY_NETSTACK "Build the embedded userspace network stack (lwIP)" OFF) +if (${CANDY_NETSTACK}) + add_compile_definitions(CANDY_NETSTACK) + include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/lwip.cmake) +endif() + add_subdirectory(candy) add_subdirectory(candy-cli) add_subdirectory(candy-service) diff --git a/candy-cli/src/config.cc b/candy-cli/src/config.cc index b5eada23..40466c39 100644 --- a/candy-cli/src/config.cc +++ b/candy-cli/src/config.cc @@ -37,6 +37,9 @@ Poco::JSON::Object arguments::json() { config.set("port", this->port); config.set("vmac", virtualMac(this->name)); config.set("expt", loadTunAddress(this->name)); + config.set("p2p-only", this->p2pOnly); + config.set("userspace-stack", this->userspaceStack); + config.set("udp-port-convergence", this->udpPortConvergence); } if (this->mode == "server") { @@ -125,6 +128,23 @@ int arguments::parse(int argc, char *argv[]) { "If omitted, auto-detected from the first physical network interface. (client only)") .metavar(""); + program.add_argument("--p2p-only") + .help("allow business packets only over direct P2P links.\n" + "WebSocket remains available for signaling, but relay forwarding is disabled.\n" + "Default is false (fall back to WebSocket relay when P2P is unavailable). (client only)") + .implicit_value(true); + + program.add_argument("--userspace-stack") + .help("terminate landing traffic with the built-in userspace network stack (lwIP)\n" + "instead of the kernel stack. Default is false (kernel). (client only)") + .implicit_value(true); + + program.add_argument("--udp-port-convergence") + .help("converge all inner UDP sources onto a single landing socket/port (single-port mode)\n" + "instead of one socket per source. Requires --userspace-stack.\n" + "Default is false (per-source full-cone). (client only)") + .implicit_value(true); + program.add_group("Server options"); program.add_argument("-d", "--dhcp") @@ -182,6 +202,9 @@ int arguments::parse(int argc, char *argv[]) { program.set_if_used("--mtu", this->mtu); program.set_if_used("--discovery", this->discovery); program.set_if_used("--route", this->routeCost); + program.set_if_used("--p2p-only", this->p2pOnly); + program.set_if_used("--userspace-stack", this->userspaceStack); + program.set_if_used("--udp-port-convergence", this->udpPortConvergence); bool needShowUsage = [&]() { if (this->mode != "client" && this->mode != "server") @@ -239,6 +262,9 @@ void arguments::parseFile(std::string cfgFile) { {"port", [&](const std::string &value) { this->port = std::stoi(value); }}, {"mtu", [&](const std::string &value) { this->mtu = std::stoi(value); }}, {"localhost", [&](const std::string &value) { this->localhost = value; }}, + {"p2p-only", [&](const std::string &value) { this->p2pOnly = (value == "true"); }}, + {"userspace-stack", [&](const std::string &value) { this->userspaceStack = (value == "true"); }}, + {"udp-port-convergence", [&](const std::string &value) { this->udpPortConvergence = (value == "true"); }}, }; auto trim = [](std::string str) { if (str.length() >= 2 && str.front() == '\"' && str.back() == '\"') { diff --git a/candy-cli/src/config.h b/candy-cli/src/config.h index dbd5e5b6..e96c02ed 100644 --- a/candy-cli/src/config.h +++ b/candy-cli/src/config.h @@ -31,6 +31,12 @@ struct arguments { int discovery = 0; int routeCost = 0; int mtu = 1400; + // 仅允许 P2P 承载业务数据;WebSocket 只保留信令(默认关闭=允许服务端中转)。 + bool p2pOnly = false; + // userspace-stack:内嵌 lwIP 落地(默认关闭=继续走内核 tun)。 + bool userspaceStack = false; + // UDP 单端口收敛:多个内部源共用一个出口 fd/端口(默认关闭=保留每源全锥形)。 + bool udpPortConvergence = false; }; int saveTunAddress(const std::string &name, const std::string &cidr); diff --git a/candy.cfg b/candy.cfg index 10c161b2..7b958905 100644 --- a/candy.cfg +++ b/candy.cfg @@ -63,5 +63,30 @@ route = 5 # results may not be the best. You can specify it manually. #localhost = "127.0.0.1" +# [Optional] Allow business traffic only over direct P2P links. +# false (default): prefer P2P and fall back to WebSocket relay through the +# server when no P2P link is available. +# true: WebSocket is used only for authentication, routing and P2P signaling; +# business packets are dropped until a direct P2P link is established. +# Public-network use requires a reachable STUN server; discovery > 0 is +# recommended for periodic P2P discovery. +p2p-only = false + # [Optional] Maximum Transmission Unit #mtu=1400 + +# [Optional] Enable the embedded userspace network stack (lwIP) for landing +# gateway. +# false (default): write decapsulated packets to the kernel tun device, relying +# on external iptables MASQUERADE / ip_forward for cross-LAN access. +# true: terminate the inner packet with an embedded userspace network stack +# (lwIP) and dial the target with a local kernel socket, replacing iptables +# MASQUERADE. Requires a build with CANDY_NETSTACK enabled. Phase 1 supports +# TCP/UDP only. +userspace-stack = false + +# [Optional] UDP single-port convergence for netstack landing mode. +# false (default): per-source UDP sockets/full-cone behavior. +# true: all inner UDP sources share one landing UDP socket/port. Requires +# userspace-stack = true. +udp-port-convergence = false diff --git a/candy/CMakeLists.txt b/candy/CMakeLists.txt index 0cf000c5..84866245 100644 --- a/candy/CMakeLists.txt +++ b/candy/CMakeLists.txt @@ -1,6 +1,10 @@ add_library(candy-library) file(GLOB_RECURSE SOURCES "src/*.cc") +# 未开启 CANDY_NETSTACK 时,netstack 目录整体不参与编译(默认关闭,零代码编译)。 +if (NOT CANDY_NETSTACK) + list(FILTER SOURCES EXCLUDE REGEX "/src/netstack/") +endif() target_sources(candy-library PRIVATE ${SOURCES}) target_include_directories(candy-library PUBLIC @@ -17,6 +21,9 @@ endif() target_link_libraries(candy-library PRIVATE Poco::Foundation Poco::JSON Poco::Net Poco::NetSSL) target_link_libraries(candy-library PRIVATE Threads::Threads) +if (${CANDY_NETSTACK}) + target_link_libraries(candy-library PRIVATE lwip) +endif() if (${CMAKE_SYSTEM_NAME} STREQUAL "Windows") target_link_libraries(candy-library PRIVATE ws2_32) diff --git a/candy/src/candy/client.cc b/candy/src/candy/client.cc index 004c712a..735b321b 100644 --- a/candy/src/candy/client.cc +++ b/candy/src/candy/client.cc @@ -99,6 +99,9 @@ bool run(const std::string &id, const Poco::JSON::Object &config) { client->setRouteCost(config.getValue("route")), client->setMtu(config.getValue("mtu")); client->setPort(config.getValue("port")); client->setLocalhost(config.getValue("localhost")); + client->setP2POnly(config.optValue("p2p-only", false)); + client->setUserspaceStack(config.optValue("userspace-stack", false)); + client->setUdpPortConvergence(config.optValue("udp-port-convergence", false)); client->run(); } candy::logger().information(Poco::format("run exit: id=%s ", id)); diff --git a/candy/src/candy/server.cc b/candy/src/candy/server.cc index bdd83476..ae4f2995 100644 --- a/candy/src/candy/server.cc +++ b/candy/src/candy/server.cc @@ -2,6 +2,8 @@ #include "candy/server.h" #include "core/server.h" #include "utils/atomic.h" +#include +#include namespace candy { namespace server { diff --git a/candy/src/core/client.cc b/candy/src/core/client.cc index 7e5847d9..f3c223dc 100644 --- a/candy/src/core/client.cc +++ b/candy/src/core/client.cc @@ -62,6 +62,26 @@ MsgQueue &Client::getWsMsgQueue() { return this->wsMsgQueue; } +MsgQueue &Client::getNetStackMsgQueue() { + return this->netstackMsgQueue; +} + +bool Client::getUserspaceStack() const { + return this->userspaceStack; +} + +bool Client::getP2POnly() const { + return this->p2pOnly; +} + +bool Client::getUdpPortConvergence() const { + return this->udpPortConvergence; +} + +int Client::getMtu() const { + return this->tun.getMTU(); +} + void Client::setPassword(const std::string &password) { ws.setPassword(password); peerManager.setPassword(password); @@ -107,9 +127,26 @@ void Client::setMtu(int mtu) { tun.setMTU(mtu); } +void Client::setP2POnly(bool enable) { + this->p2pOnly = enable; +} + +void Client::setUserspaceStack(bool enable) { + this->userspaceStack = enable; +} + +void Client::setUdpPortConvergence(bool enable) { + this->udpPortConvergence = enable; +} + void Client::run() { this->running.store(true); +#ifdef CANDY_NETSTACK + if (netstack.run(this)) { + return; + } +#endif if (ws.run(this)) { return; } @@ -123,10 +160,14 @@ void Client::run() { ws.wait(); tun.wait(); peerManager.wait(); +#ifdef CANDY_NETSTACK + netstack.wait(); +#endif wsMsgQueue.clear(); tunMsgQueue.clear(); peerMsgQueue.clear(); + netstackMsgQueue.clear(); } bool Client::isRunning() { diff --git a/candy/src/core/client.h b/candy/src/core/client.h index 61fd6795..c8c39371 100644 --- a/candy/src/core/client.h +++ b/candy/src/core/client.h @@ -7,6 +7,9 @@ #include "tun/tun.h" #include "utils/atomic.h" #include "websocket/client.h" +#ifdef CANDY_NETSTACK +#include "netstack/netstack.h" +#endif #include #include #include @@ -42,6 +45,10 @@ class Client { void setExptTunAddress(const std::string &cidr); void setVirtualMac(const std::string &vmac); + void setP2POnly(bool enable); + void setUserspaceStack(bool enable); + void setUdpPortConvergence(bool enable); + void run(); bool isRunning(); void shutdown(); @@ -57,16 +64,29 @@ class Client { MsgQueue &getTunMsgQueue(); MsgQueue &getPeerMsgQueue(); MsgQueue &getWsMsgQueue(); + MsgQueue &getNetStackMsgQueue(); + + bool getP2POnly() const; + bool getUserspaceStack() const; + bool getUdpPortConvergence() const; + int getMtu() const; private: - MsgQueue tunMsgQueue, peerMsgQueue, wsMsgQueue; + MsgQueue tunMsgQueue, peerMsgQueue, wsMsgQueue, netstackMsgQueue; Tun tun; PeerManager peerManager; WebSocketClient ws; +#ifdef CANDY_NETSTACK + NetStack netstack; +#endif private: std::string tunName; + bool p2pOnly = false; + bool userspaceStack = false; + // UDP 单端口收敛开关(默认关闭=每源全锥形)。仅在 userspaceStack=true 时生效。 + bool udpPortConvergence = false; }; } // namespace candy diff --git a/candy/src/core/message.h b/candy/src/core/message.h index 0d3f6fa8..86f2e6c7 100644 --- a/candy/src/core/message.h +++ b/candy/src/core/message.h @@ -16,6 +16,7 @@ enum class MsgKind { TRYP2P, PUBINFO, DISCOVERY, + NETSTACK, }; struct Msg { diff --git a/candy/src/netstack/netstack.cc b/candy/src/netstack/netstack.cc new file mode 100644 index 00000000..df1ed585 --- /dev/null +++ b/candy/src/netstack/netstack.cc @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: MIT +#include "netstack/netstack.h" +#include "core/client.h" +#include "core/message.h" +#include "netstack/session_tcp.h" +#include "netstack/session_udp.h" +#include "netstack/udpmux.h" +#include "utils/log.h" +#include +#include +#include +#include +#include + +#include "lwip/init.h" +#include "lwip/pbuf.h" +#include "lwip/priv/tcp_priv.h" +#include "lwip/tcp.h" +#include "lwip/timeouts.h" +#include "lwip/udp.h" +#include "netif/etharp.h" + +namespace candy { + +NetStack::NetStack() : client(nullptr), running(false), listenPcb(nullptr), udpListenPcb(nullptr) { + std::memset(&this->lwipNetif, 0, sizeof(this->lwipNetif)); +} + +NetStack::~NetStack() { + shutdown(); + wait(); +} + +Client *NetStack::getClient() { + return this->client; +} + +Reactor &NetStack::getReactor() { + return this->reactor; +} + +Outbound &NetStack::getOutbound() { + // 当前仅支持内核 socket 直连落地。 + return this->directOutbound; +} + +int NetStack::run(Client *client) { + this->client = client; + + // userspace 用户态协议栈的启用判定收敛在本模块内部,使 Client::run 里各模块调用 + // 视觉平行;未启用时本模块不启动任何线程、不占用资源。 + if (!client->getUserspaceStack()) { + return 0; + } + + this->running.store(true); + + if (this->reactor.start()) { + candy::logger().fatal("netstack reactor start failed"); + return -1; + } + + this->stackThread = std::thread([this] { + candy::logger().debug("start thread: netstack"); + try { + if (initStack()) { + candy::logger().fatal("netstack init failed"); + getClient()->shutdown(); + } else { + loop(); + teardownStack(); + } + } catch (const std::exception &e) { + candy::logger().error(Poco::format("netstack thread exception: %s", std::string(e.what()))); + getClient()->shutdown(); + } + candy::logger().debug("stop thread: netstack"); + }); + + this->msgThread = std::thread([this] { + candy::logger().debug("start thread: netstack msg"); + try { + while (getClient()->isRunning()) { + if (handleQueue()) { + break; + } + } + getClient()->shutdown(); + } catch (const std::exception &e) { + candy::logger().error(Poco::format("netstack msg thread exception: %s", std::string(e.what()))); + getClient()->shutdown(); + } + candy::logger().debug("stop thread: netstack msg"); + }); + return 0; +} + +int NetStack::wait() { + // 内部驱动 stack loop 退出,无需 Client 显式先调 shutdown(),保持与其他模块 + // 「run + wait」两段式调用对称。未启用 userspace 时线程均未创建,join 为空操作。 + shutdown(); + if (this->stackThread.joinable()) { + this->stackThread.join(); + } + if (this->msgThread.joinable()) { + this->msgThread.join(); + } + this->reactor.stop(); + return 0; +} + +int NetStack::handleQueue() { + Msg msg = this->client->getNetStackMsgQueue().read(); + switch (msg.kind) { + case MsgKind::TIMEOUT: + break; + case MsgKind::NETSTACK: + input(std::move(msg.data)); + break; + default: + candy::logger().warning(Poco::format("unexcepted netstack message type: %d", static_cast(msg.kind))); + break; + } + return 0; +} + +void NetStack::shutdown() { + this->running.store(false); + this->stackTaskCond.notify_all(); +} + +void NetStack::input(std::string packet) { + if (!this->running.load()) { + return; + } + postToStack([this, packet = std::move(packet)]() mutable { handleInput(std::move(packet)); }); +} + +void NetStack::postToStack(std::function task) { + { + std::unique_lock lock(this->stackTaskMutex); + this->stackTasks.push(std::move(task)); + } + this->stackTaskCond.notify_one(); +} + +int NetStack::initStack() { + static std::once_flag lwipInitFlag; + std::call_once(lwipInitFlag, [] { lwip_init(); }); + + // 这里的 addr/mask 是 lwIP 内部 netif 的占位地址,不是 candy 的虚拟网卡地址, + // 二者无需匹配:本 netif 配合下方 NETIF_FLAG_PRETEND_TCP 作为「捕获全部」网卡, + // 把喂进来的 IPIP 内层包无条件交给 lwIP 协议栈终结(lwIP 不会用这个地址回 ARP, + // 也不会据此做路由判定)。选 10.255.255.254/32 只是取一个不与业务网段冲突的占位值。 + ip4_addr_t addr, mask, gw; + IP4_ADDR(&addr, 10, 255, 255, 254); + IP4_ADDR(&mask, 255, 255, 255, 255); + ip4_addr_set_any(&gw); + + if (netif_add(&this->lwipNetif, &addr, &mask, &gw, this, netifInitTrampoline, ip_input) == nullptr) { + candy::logger().fatal("netstack netif_add failed"); + return -1; + } + netif_set_up(&this->lwipNetif); + netif_set_link_up(&this->lwipNetif); + netif_set_default(&this->lwipNetif); + netif_set_flags(&this->lwipNetif, NETIF_FLAG_PRETEND_TCP); + + // netif MTU 必须扣除 IPIP 外层头(20B),否则落地端按 1500 协商出的满载内层包 + // 封装后超过隧道 tun MTU(client mtu),导致大包被丢弃、TCP 重传超时。 + int tunMtu = this->client ? this->client->getMtu() : 1400; + int netifMtu = tunMtu - (int)sizeof(IP4Header); + if (netifMtu < 576) { + netifMtu = 576; + } + this->lwipNetif.mtu = (u16_t)netifMtu; + + struct tcp_pcb *pcb = tcp_new_ip_type(IPADDR_TYPE_ANY); + if (pcb == nullptr) { + candy::logger().fatal("netstack tcp_new failed"); + return -1; + } + tcp_bind_netif(pcb, &this->lwipNetif); + if (tcp_bind(pcb, nullptr, 0) != ERR_OK) { + candy::logger().fatal("netstack tcp_bind failed"); + return -1; + } + this->listenPcb = tcp_listen(pcb); + if (this->listenPcb == nullptr) { + candy::logger().fatal("netstack tcp_listen failed"); + return -1; + } + tcp_arg(this->listenPcb, this); + tcp_accept(this->listenPcb, acceptTrampoline); + + // UDP 捕获所有目的:建一个绑定本 netif 的 udp_pcb,bind(NULL,0) 接管任意目的。 + // 依赖 netif 的 NETIF_FLAG_PRETEND_TCP:收到首个数据报时 lwIP 会克隆出一个 + // 已 connect 源端的 npcb 并通过回调交给我们,由此建立四元组伪会话。 + this->udpListenPcb = udp_new_ip_type(IPADDR_TYPE_ANY); + if (this->udpListenPcb == nullptr) { + candy::logger().fatal("netstack udp_new failed"); + return -1; + } + udp_bind_netif(this->udpListenPcb, &this->lwipNetif); + if (udp_bind(this->udpListenPcb, nullptr, 0) != ERR_OK) { + candy::logger().fatal("netstack udp_bind failed"); + return -1; + } + udp_recv(this->udpListenPcb, udpRecvTrampoline, this); + return 0; +} + +void NetStack::teardownStack() { + // lwIP 进程级单例:重连时必须清理本次注册的 netif / pcb / 会话, + // 否则 netif 会在全局 netif_list 中累积,最终触发 "too many netifs" 断言崩溃。 + { + std::unique_lock lock(this->sessionMutex); + for (auto &kv : this->sessions) { + if (kv.second) { + kv.second->shutdownFromStack(); + } + } + this->sessions.clear(); + } + // UDP 伪会话仅 NetStack 线程访问,此处即在 NetStack 线程,直接清理。 + for (auto &kv : this->udpSessions) { + if (kv.second) { + kv.second->shutdownFromStack(); + } + } + this->udpSessions.clear(); + // 单端口收敛:整栈拆除时统一 shutdown UdpMux(清四表 + 交 reactor 关全局 fd)。 + if (this->udpMux) { + this->udpMux->shutdown(); + this->udpMux.reset(); + } + { + std::unique_lock lock(this->peerMapMutex); + this->vnetPeerMap.clear(); + } + if (this->udpListenPcb != nullptr) { + udp_recv(this->udpListenPcb, nullptr, nullptr); + udp_remove(this->udpListenPcb); + this->udpListenPcb = nullptr; + } + if (this->listenPcb != nullptr) { + tcp_close(this->listenPcb); + this->listenPcb = nullptr; + } + netif_remove(&this->lwipNetif); + std::memset(&this->lwipNetif, 0, sizeof(this->lwipNetif)); +} + +void NetStack::loop() { + auto lastReap = std::chrono::steady_clock::now(); + while (this->running.load()) { + std::function task; + { + std::unique_lock lock(this->stackTaskMutex); + this->stackTaskCond.wait_for(lock, std::chrono::milliseconds(250), + [this] { return !this->stackTasks.empty() || !this->running.load(); }); + if (!this->stackTasks.empty()) { + task = std::move(this->stackTasks.front()); + this->stackTasks.pop(); + } + } + if (task) { + task(); + } + sys_check_timeouts(); + + // 节流:每 10s 扫描一次 UDP 伪会话,回收空闲超时项。 + auto now = std::chrono::steady_clock::now(); + if (now - lastReap >= std::chrono::seconds(10)) { + reapIdleUdpSessions(); + // 收敛模式:同频老化 UdpMux 的 endpointOwner / stunTxn。 + if (this->udpMux) { + this->udpMux->reap(); + } + lastReap = now; + } + } +} + +void NetStack::reapIdleUdpSessions() { + // 仅 NetStack 线程访问 udpSessions,无需加锁。 + // UDP 无连接:60s 无收发则判定空闲,强制关闭并移除(shutdownFromStack 会 + // 移除 npcb 并交由 reactor 关闭 fd)。注意 shutdownFromStack 内不修改 udpSessions, + // 故可安全地在遍历后统一 erase。 + auto now = std::chrono::steady_clock::now(); + int reaped = 0; + for (auto it = this->udpSessions.begin(); it != this->udpSessions.end();) { + if (it->second && now - it->second->lastActive() > std::chrono::seconds(60)) { + it->second->shutdownFromStack(); + it = this->udpSessions.erase(it); + ++reaped; + } else { + ++it; + } + } + if (reaped > 0) { + candy::logger().debug( + Poco::format("netstack reap idle udp sessions: %d reaped, %zu remain", reaped, this->udpSessions.size())); + } +} + +void NetStack::handleInput(std::string packet) { + if (packet.size() < sizeof(IP4Header)) { + return; + } + IP4Header *header = (IP4Header *)packet.data(); + IP4 vnetPeer; + if (header->isIPIP()) { + vnetPeer = header->saddr; + packet.erase(0, sizeof(IP4Header)); + } + if (packet.size() < sizeof(IP4Header)) { + return; + } + IP4Header *inner = (IP4Header *)packet.data(); + candy::logger().debug(Poco::format("netstack input: vnetPeer=%s %s -> %s proto=%d", vnetPeer.toString(), + inner->saddr.toString(), inner->daddr.toString(), (int)inner->protocol)); + // 本次增量发版仅支持 userspace TCP/UDP 组网:非 TCP(0x06)/UDP(0x11) 一律丢弃 + // (ICMP 等待后续迭代再支持;PRETEND netif 本也只接受 TCP/UDP)。 + if (inner->protocol != 0x06 && inner->protocol != 0x11) { + return; + } + feedToLwip(packet, vnetPeer); +} + +void NetStack::feedToLwip(const std::string &innerPacket, IP4 vnetPeer) { + IP4Header *header = (IP4Header *)innerPacket.data(); + if (!vnetPeer.empty()) { + auto now = std::chrono::steady_clock::now(); + std::unique_lock lock(this->peerMapMutex); + this->vnetPeerMap[uint32_t(header->saddr)] = PeerEntry{vnetPeer, now}; + // 惰性老化清理:表过大时清掉 180s 空闲过期项,避免长期运行无界累积。 + if (this->vnetPeerMap.size() > 4096) { + for (auto it = this->vnetPeerMap.begin(); it != this->vnetPeerMap.end();) { + if (now - it->second.lastSeen > std::chrono::seconds(180)) { + it = this->vnetPeerMap.erase(it); + } else { + ++it; + } + } + } + } + + struct pbuf *p = pbuf_alloc(PBUF_RAW, innerPacket.size(), PBUF_RAM); + if (p == nullptr) { + candy::logger().warning("netstack pbuf_alloc failed"); + return; + } + pbuf_take(p, innerPacket.data(), innerPacket.size()); + err_t e = this->lwipNetif.input(p, &this->lwipNetif); + if (e != ERR_OK) { + candy::logger().warning(Poco::format("netstack netif.input failed: %d", (int)e)); + pbuf_free(p); + } +} + +void NetStack::output(const std::string &innerPacket) { + if (innerPacket.size() < sizeof(IP4Header)) { + return; + } + IP4Header *inner = (IP4Header *)innerPacket.data(); + IP4 vnetPeer; + { + std::unique_lock lock(this->peerMapMutex); + auto it = this->vnetPeerMap.find(uint32_t(inner->daddr)); + if (it != this->vnetPeerMap.end()) { + vnetPeer = it->second.peer; + it->second.lastSeen = std::chrono::steady_clock::now(); + } + } + if (vnetPeer.empty()) { + candy::logger().warning(Poco::format("netstack output drop: no vnetPeer for %s", inner->daddr.toString())); + return; + } + + std::string buffer = innerPacket; + buffer.insert(0, sizeof(IP4Header), 0); + IP4Header *outer = (IP4Header *)buffer.data(); + outer->protocol = 0x04; + outer->saddr = this->client->address(); + outer->daddr = vnetPeer; + + this->client->getPeerMsgQueue().write(Msg(MsgKind::PACKET, std::move(buffer))); +} + +void NetStack::removeSession(struct tcp_pcb *pcb) { + std::unique_lock lock(this->sessionMutex); + this->sessions.erase(pcb); +} + +void NetStack::removeUdpSession(const std::string &key) { + // 仅 NetStack 线程调用,udpSessions 无需加锁。 + this->udpSessions.erase(key); +} + +struct netif &NetStack::getNetif() { + return this->lwipNetif; +} + +err_t NetStack::netifInitTrampoline(struct netif *netif) { + NetStack *self = (NetStack *)netif->state; + return self->onNetifInit(netif); +} + +err_t NetStack::outputTrampoline(struct netif *netif, struct pbuf *p, const ip4_addr_t *ipaddr) { + NetStack *self = (NetStack *)netif->state; + return self->onOutput(netif, p, ipaddr); +} + +err_t NetStack::acceptTrampoline(void *arg, struct tcp_pcb *newpcb, err_t err) { + NetStack *self = (NetStack *)arg; + return self->onAccept(newpcb, err); +} + +void NetStack::udpRecvTrampoline(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + NetStack *self = (NetStack *)arg; + self->onUdpRecv(pcb, p, addr, port); +} + +err_t NetStack::onNetifInit(struct netif *netif) { + netif->name[0] = 'c'; + netif->name[1] = 'd'; + netif->mtu = 1500; + netif->output = outputTrampoline; + return ERR_OK; +} + +err_t NetStack::onOutput(struct netif *netif, struct pbuf *p, const ip4_addr_t *ipaddr) { + std::string buffer; + buffer.resize(p->tot_len); + pbuf_copy_partial(p, buffer.data(), p->tot_len, 0); + if (buffer.size() >= sizeof(IP4Header)) { + IP4Header *h = (IP4Header *)buffer.data(); + candy::logger().debug(Poco::format("netstack onOutput: %s -> %s proto=%d len=%zu", h->saddr.toString(), + h->daddr.toString(), (int)h->protocol, buffer.size())); + } + output(buffer); + return ERR_OK; +} + +err_t NetStack::onAccept(struct tcp_pcb *newpcb, err_t err) { + candy::logger().debug(Poco::format("netstack onAccept: err=%d pcb=%p", (int)err, (void *)newpcb)); + if (err != ERR_OK || newpcb == nullptr) { + return ERR_VAL; + } + if (!this->running.load()) { + return ERR_RST; + } + + auto session = std::make_shared(this, newpcb); + if (session->start()) { + return ERR_RST; + } + { + std::unique_lock lock(this->sessionMutex); + this->sessions[newpcb] = session; + } + return ERR_OK; +} + +void NetStack::onUdpRecv(struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + // 首包路径:lwIP 已为该「源二元组」克隆出 npcb(pcb 参数), + // addr/port = 首包目的,pcb->remote_ip/remote_port = 源端(dev1)。 + // 全锥形:clone 天然「一源一 npcb」(只按源聚合),故 onUdpRecv 对每个源仅触发一次, + // 无需 find-or-create。会话只按源二元组建立;后续各目的由每流 handler 从 pcb->local_* + // per-packet 读取。建会话后**不释放 p**:lwIP 收尾 goto again 把该数据报重投到每流 handler。 + if (p == nullptr) { + return; + } + if (!this->running.load()) { + udp_remove(pcb); + pbuf_free(p); + return; + } + + IP4 origDst; + std::memcpy(&origDst, &ip_2_ip4(addr)->addr, sizeof(uint32_t)); + uint16_t origDstPort = port; + IP4 origSrc; + std::memcpy(&origSrc, &ip_2_ip4(&pcb->remote_ip)->addr, sizeof(uint32_t)); + uint16_t origSrcPort = pcb->remote_port; + + // 目的仅用于首包日志(真实目的为 per-packet),会话本体只认源二元组。 + candy::logger().debug(Poco::format("netstack onUdpRecv src %s:%hu (first dst %s:%hu)", origSrc.toString(), origSrcPort, + origDst.toString(), origDstPort)); + + // 模式选择:读客户端开关。收敛模式下所有源共用全局 UdpMux(惰性首用即建)。 + bool converged = this->client && this->client->getUdpPortConvergence(); + if (converged && !this->udpMux) { + this->udpMux = std::make_shared(this); + if (this->udpMux->start()) { + // 配置明确选择收敛模式时,不得静默回退到每源模式:否则出口语义与用户选择不一致。 + candy::logger().error("netstack udpmux start failed while UDP convergence is enabled"); + this->udpMux.reset(); + udp_recv(pcb, nullptr, nullptr); + udp_remove(pcb); + pbuf_free(p); + this->client->shutdown(); + return; + } + } + + auto session = std::make_shared(this, pcb, origSrc, origSrcPort, converged); + if (session->start()) { + udp_recv(pcb, nullptr, nullptr); + udp_remove(pcb); + pbuf_free(p); + return; + } + this->udpSessions[session->key()] = session; // key = 源二元组 + // 不释放 p:交还 lwIP(goto again 重投到该 npcb 的每流 handler)。 +} + +// ===================== UDP 单端口收敛协作接口(仅 NetStack 线程) ===================== + +void NetStack::sendUdpConverged(const std::string &innerSrc, uint32_t dstIpBe, uint16_t dstPortHost, std::string data) { + if (!this->udpMux) { + return; + } + this->udpMux->sendFromSource(innerSrc, dstIpBe, dstPortHost, std::move(data)); +} + +void NetStack::onUdpSourceGone(const std::string &innerSrc) { + if (!this->udpMux) { + return; + } + this->udpMux->onSourceGone(innerSrc); +} + +void NetStack::injectUdpReply(const std::string &innerSrc, uint32_t rIpBe, uint16_t rPortHost, std::string data) { + // innerNpcb 复用 udpSessions:按内部源定位对应 SessionUdp,以实际对端为源注入回 lwIP。 + // 源可能已老化消亡(会话被回收),此时静默丢弃即可。 + auto it = this->udpSessions.find(innerSrc); + if (it == this->udpSessions.end() || !it->second) { + return; + } + it->second->replyToLwip(rIpBe, rPortHost, std::move(data)); +} + +} // namespace candy diff --git a/candy/src/netstack/netstack.h b/candy/src/netstack/netstack.h new file mode 100644 index 00000000..d8969843 --- /dev/null +++ b/candy/src/netstack/netstack.h @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_NETSTACK_H +#define CANDY_NETSTACK_NETSTACK_H + +#include "core/net.h" +#include "netstack/outbound.h" +#include "netstack/reactor.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Windows:必须在 lwIP 头之前引入 winsock(sockcompat.h 内已按平台处理),否则 +// lwip/def.h 的 htonl 宏会改写 winsock 中 htonl 的原型,导致 lwip_htonl 声明冲突。 +#include "netstack/sockcompat.h" + +#include "lwip/ip_addr.h" +#include "lwip/netif.h" +#include "lwip/tcp.h" +#include "lwip/udp.h" + +namespace candy { + +class Client; +class SessionTcp; +class SessionUdp; +class UdpMux; + +// NetStack:独占 lwIP(NO_SYS=1 单线程 raw API)的模块。 +// 持有一个 NetStack 线程驱动 lwIP 收发与定时器,一个 Reactor 线程管理落地 fd。 +// 落地端在 userspace 模式下,把 IPIP 内层包喂进 lwIP 终结,TCP 由内核 socket 直连目标。 +class NetStack { +public: + NetStack(); + ~NetStack(); + + int run(Client *client); + int wait(); + void shutdown(); + + // 把待终结的 IP 包投递给 NetStack(来自 tun 落地分支,含 IPIP 外层)。 + void input(std::string packet); + // 供 Session 使用:在 NetStack 线程内执行任务(保证 lwIP API 线程安全)。 + void postToStack(std::function task); + Reactor &getReactor(); + // 落地拨号出站:当前仅支持内核 socket 直连(DirectOutbound)。 + Outbound &getOutbound(); + // UDP 回包注入用:拿到本模块的 lwIP netif(仅 NetStack 线程使用)。 + struct netif &getNetif(); + + // 回包:lwIP netif->output 产生的 IP 包,按连接上下文重新 IPIP 封装送回源端。 + void output(const std::string &innerPacket); + + // Session 结束时从会话表移除。 + void removeSession(struct tcp_pcb *pcb); + // UDP 会话结束时从 UDP 会话表移除(按源二元组 key)。仅 NetStack 线程调用。 + void removeUdpSession(const std::string &key); + + // ---- UDP 单端口收敛(converged 模式)协作接口,均仅 NetStack 线程调用 ---- + // 某内部源 innerSrc 发往 (dstIpBe:dstPortHost) 的一份报文,转交全局 UdpMux 发送。 + void sendUdpConverged(const std::string &innerSrc, uint32_t dstIpBe, uint16_t dstPortHost, std::string data); + // 某内部源整体消亡,通知 UdpMux 级联清掉该源名下全部 endpointOwner 条目。 + void onUdpSourceGone(const std::string &innerSrc); + // UdpMux demux 出内部源后,把回包定位到对应 SessionUdp 注入回 lwIP。 + void injectUdpReply(const std::string &innerSrc, uint32_t rIpBe, uint16_t rPortHost, std::string data); + + Client *getClient(); + +private: + int initStack(); + void teardownStack(); + void loop(); + // 读取本模块 MsgQueue(MsgKind::NETSTACK 落地入站包),驱动 input()。 + int handleQueue(); + void handleInput(std::string packet); + void feedToLwip(const std::string &innerPacket, IP4 vnetPeer); + // UDP 伪会话空闲超时回收(仅 NetStack 线程,loop 中节流调用)。 + void reapIdleUdpSessions(); + + // lwIP 回调跳板 + static err_t netifInitTrampoline(struct netif *netif); + static err_t outputTrampoline(struct netif *netif, struct pbuf *p, const ip4_addr_t *ipaddr); + static err_t acceptTrampoline(void *arg, struct tcp_pcb *newpcb, err_t err); + static void udpRecvTrampoline(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + + err_t onNetifInit(struct netif *netif); + err_t onOutput(struct netif *netif, struct pbuf *p, const ip4_addr_t *ipaddr); + err_t onAccept(struct tcp_pcb *newpcb, err_t err); + // UDP 数据报到达:addr/port=源端(dev1),pcb->local=首包目的。全锥形下 clone 一源一 npcb, + // 故对每个源仅触发一次,会话按源二元组建立。仅 NetStack 线程。 + void onUdpRecv(struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + +private: + Client *client; + + std::thread stackThread; + std::thread msgThread; + std::atomic running; + + struct netif lwipNetif; + struct tcp_pcb *listenPcb; + // UDP 捕获所有目的的伪监听 pcb(绑定 netif、bind(NULL,0),收首包克隆出按源聚合的 npcb)。 + struct udp_pcb *udpListenPcb; + + Reactor reactor; + + // 落地出站:DirectOutbound(内核 socket 直连)。userspace 局域网组网的落地方式。 + DirectOutbound directOutbound; + + // NetStack 线程任务队列(用带超时读驱动定时器) + std::mutex stackTaskMutex; + std::condition_variable stackTaskCond; + std::queue> stackTasks; + + // 内层源 IP(dev1) -> vnetPeer(源网关虚拟IP),供回包寻址。 + // 带最近活跃时间戳,惰性老化清理(180s 空闲过期),避免长期运行无界累积。 + struct PeerEntry { + IP4 peer; + std::chrono::steady_clock::time_point lastSeen; + }; + std::mutex peerMapMutex; + std::unordered_map vnetPeerMap; + + std::mutex sessionMutex; + std::unordered_map> sessions; + // UDP 会话表:源二元组 key -> SessionUdp。仅 NetStack 线程访问,无需加锁。 + std::unordered_map> udpSessions; + + // UDP 单端口收敛多路复用器:仅在 udpPortConvergence 开启时创建(惰性首用即建), + // 收敛模式下所有内部源共用它的单一落地 fd。仅 NetStack 线程访问。 + std::shared_ptr udpMux; +}; + +} // namespace candy + +#endif diff --git a/candy/src/netstack/outbound.cc b/candy/src/netstack/outbound.cc new file mode 100644 index 00000000..e31603b1 --- /dev/null +++ b/candy/src/netstack/outbound.cc @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +#include "netstack/outbound.h" +#include "netstack/sockcompat.h" +#include "utils/log.h" +#include + +// 说明(回应 review:优先用 Poco 封装):此处属「不得已」而使用平台相关裸 socket。 +// 原因:落地 fd 的生命周期由 Reactor 按裸 int fd 统一管理(注册/注销/关闭), +// 而 Poco::Net::StreamSocket/DatagramSocket 会自行持有并在析构时关闭 fd, +// 与 Reactor 的 fd 所有权模型冲突,若混用会导致 double-close。故这里只借用 +// 系统 socket() 拿到裸 fd 交给 Reactor,平台差异已收敛在 sockcompat.h 内, +// 本文件仅剩 sockaddr_in 组装这一处必须用平台头。 +#if defined(__linux__) || defined(__APPLE__) +#include +#include +#elif defined(_WIN32) || defined(_WIN64) +#include +#endif + +namespace candy { + +int DirectOutbound::dialTcp(const Endpoint &dst) { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + // 建 socket + 非阻塞 + 发起 connect 到 dst。EINPROGRESS 视为成功。 + int fd = (int)::socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + candy::logger().warning(Poco::format("direct outbound tcp socket failed: %s", netErrStr(netLastError()))); + return -1; + } + netSetNonBlocking(fd); + + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = htons(dst.port); + addr.sin_addr.s_addr = uint32_t(dst.host); + + int ret = ::connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + if (ret != 0 && !netInProgress(netLastError())) { + candy::logger().warning(Poco::format("direct outbound tcp connect %s:%hu failed: %s", dst.host.toString(), dst.port, + netErrStr(netLastError()))); + netClose(fd); + return -1; + } + return fd; +#else + return -1; +#endif +} + +int DirectOutbound::dialUdp() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + int fd = (int)::socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) { + candy::logger().warning(Poco::format("direct outbound udp socket failed: %s", netErrStr(netLastError()))); + return -1; + } + netSetNonBlocking(fd); + + // 全锥形(Full Cone):不 connect 固定对端。bind(INADDR_ANY,0) 让内核立即分配一个 + // 固定出口端口,全生命周期不变;对所有目的复用同一 (出口IP,端口)(endpoint-independent + // mapping),后续 sendto 发往任意目的、recvfrom 收任意对端回包。出口源 IP 仍由内核按 + // 默认路由自动填为本网关出口 IP(等价 MASQUERADE)。 + if (netBindAny(fd) != 0) { + candy::logger().warning(Poco::format("direct outbound udp bind failed: %s", netErrStr(netLastError()))); + netClose(fd); + return -1; + } + return fd; +#else + return -1; +#endif +} + +} // namespace candy diff --git a/candy/src/netstack/outbound.h b/candy/src/netstack/outbound.h new file mode 100644 index 00000000..2eadcbb7 --- /dev/null +++ b/candy/src/netstack/outbound.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_OUTBOUND_H +#define CANDY_NETSTACK_OUTBOUND_H + +#include "core/net.h" +#include +#include + +namespace candy { + +// 落地目的端点:一条被 lwIP 终结的流要连到的目标地址与端口。 +struct Endpoint { + IP4 host; + uint16_t port; +}; + +// Outbound:出站抽象。把"一条已被本机 lwIP 终结的流如何落地拨号"从 Session 中解耦。 +// +// 当前增量发版仅保留 userspace 局域网组网所需的内核 socket 直连落地(DirectOutbound), +// socks5/mesh 等出站方式待后续迭代再引入。 +// +// 设计约束(与现有 Session 实现保持一致): +// - dialTcp/dialUdp 只负责"建 socket + 设非阻塞 + 发起 connect",返回一个已发起 +// 连接的非阻塞 fd(失败返回 -1)。后续 reactor 注册、可读可写事件、双向 splice、 +// 背压等全部仍由 Session 自身处理。 +class Outbound { +public: + virtual ~Outbound() = default; + + // 出站名称(direct)。 + virtual std::string name() const = 0; + + // L4 终结型出站:为一条已终结的 TCP 流建立落地通道,返回已发起 connect 的非阻塞 + // fd(EINPROGRESS 视为成功),失败返回 -1。默认不支持。 + virtual int dialTcp(const Endpoint &dst) { + return -1; + } + + // L4 终结型出站:为一条已终结的 UDP 流建立落地出口 fd。 + // 全锥形(Full Cone):不 connect 固定目的,而是 bind 一个固定出口端口的 unconnected + // socket,后续用 sendto 发往任意多目的、用 recvfrom 收任意对端回包,返回非阻塞 fd, + // 失败返回 -1。默认不支持。 + virtual int dialUdp() { + return -1; + } +}; + +// DirectOutbound:内核 socket 直连落地。 +// 源地址由内核在发起连接时自动填为本网关出口 IP,等价 MASQUERADE。 +class DirectOutbound : public Outbound { +public: + std::string name() const override { + return "direct"; + } + + int dialTcp(const Endpoint &dst) override; + int dialUdp() override; +}; + +} // namespace candy + +#endif diff --git a/candy/src/netstack/reactor.cc b/candy/src/netstack/reactor.cc new file mode 100644 index 00000000..7e6cb675 --- /dev/null +++ b/candy/src/netstack/reactor.cc @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +#include "netstack/reactor.h" +#include "utils/log.h" +#include +#include +#include +#include + +namespace candy { + +// 非拥有的 SocketImpl:包裹一个由外部(session)创建并负责关闭的落地 fd。 +// 关键:析构前把 _sockfd 复位为无效句柄(reset() 不做 close),使基类 ~SocketImpl 的 +// close() 成为空操作,避免与 session 的 netClose(fd) 造成双重关闭(fd 可能已被复用)。 +class NonOwningSocketImpl : public Poco::Net::StreamSocketImpl { +public: + explicit NonOwningSocketImpl(int fd) : Poco::Net::StreamSocketImpl(fd) {} + ~NonOwningSocketImpl() override { + reset(); + } +}; + +// 非拥有的 Socket:仅用于把落地 fd 交给 PollSet 监听,不持有 fd 生命周期。 +class NonOwningSocket : public Poco::Net::Socket { +public: + // 说明:Poco::Net::Socket(SocketImpl*) 是框架约定的唯一构造入口——SocketImpl 派生自 + // RefCountedObject,Socket 会接管其引用计数并在析构时 release(),故此处 new 出来的 impl + // 生命周期由 Poco 内部托管,并非需要手动 delete 的裸指针。 + explicit NonOwningSocket(int fd) : Poco::Net::Socket(new NonOwningSocketImpl(fd)) {} +}; + +// 每个落地 fd 对应一个 IoWatcher:持有非拥有的 Poco::Net::Socket、fd 与事件回调。 +// 仅 reactor 线程创建/销毁/访问。 +struct IoWatcher { + int fd; + Poco::Net::Socket socket; + Reactor::EventHandler handler; + ReactorEvent events; +}; + +static int toPocoMode(ReactorEvent events) { + int mode = 0; + if (events & ReactorEvent::READ) { + mode |= Poco::Net::PollSet::POLL_READ; + } + if (events & ReactorEvent::WRITE) { + mode |= Poco::Net::PollSet::POLL_WRITE; + } + return mode; +} + +static ReactorEvent fromPocoMode(int mode) { + ReactorEvent events = ReactorEvent::NONE; + if (mode & Poco::Net::PollSet::POLL_READ) { + events = events | ReactorEvent::READ; + } + if (mode & Poco::Net::PollSet::POLL_WRITE) { + events = events | ReactorEvent::WRITE; + } + if (mode & Poco::Net::PollSet::POLL_ERROR) { + events = events | ReactorEvent::FAILURE; + } + return events; +} + +Reactor::Reactor() : running(false) {} + +Reactor::~Reactor() { + stop(); +} + +int Reactor::start() { + this->pollSet = std::make_unique(); + this->running.store(true); + this->thread = std::thread([this] { this->loop(); }); + // 在启动线程内确定性记录 reactor 线程 id(早于任何跨线程 add/mod/del 访问), + // 避免在 loop() 内写、其他线程读 loopThreadId 造成的数据竞争。 + this->loopThreadId = this->thread.get_id(); + return 0; +} + +void Reactor::stop() { + if (!this->running.exchange(false)) { + if (this->thread.joinable()) { + this->thread.join(); + } + return; + } + // 唤醒 reactor 线程:poll 立即返回,循环检测到 !running 后退出。 + if (this->pollSet) { + this->pollSet->wakeUp(); + } + if (this->thread.joinable()) { + this->thread.join(); + } + + // 线程已退出,安全清理剩余 watcher 与 pollSet(仅本线程访问)。unique_ptr 自动释放。 + this->ioWatchers.clear(); + this->pollSet.reset(); +} + +bool Reactor::onLoopThread() const { + return std::this_thread::get_id() == this->loopThreadId; +} + +// ===================== 对外接口:按线程归属直接应用或投递 ===================== + +int Reactor::add(int fd, ReactorEvent events, EventHandler handler) { + if (onLoopThread()) { + applyAdd(fd, events, std::move(handler)); + } else { + // C++17 init-capture:把 handler 移动进 lambda,无需 shared_ptr 包裹。 + post([this, fd, events, handler = std::move(handler)]() mutable { this->applyAdd(fd, events, std::move(handler)); }); + } + return 0; +} + +int Reactor::mod(int fd, ReactorEvent events) { + if (onLoopThread()) { + applyMod(fd, events); + } else { + post([this, fd, events] { this->applyMod(fd, events); }); + } + return 0; +} + +int Reactor::del(int fd) { + if (onLoopThread()) { + applyDel(fd); + } else { + post([this, fd] { this->applyDel(fd); }); + } + return 0; +} + +// ===================== 仅 reactor 线程:实际操作 PollSet ===================== + +void Reactor::applyAdd(int fd, ReactorEvent events, EventHandler handler) { + auto it = this->ioWatchers.find(fd); + if (it != this->ioWatchers.end()) { + // 已存在:等价于替换 handler 与兴趣集(防御性,正常不应发生)。 + IoWatcher &iw = *it->second; + iw.handler = std::move(handler); + iw.events = events; + this->pollSet->update(iw.socket, toPocoMode(events)); + return; + } + auto iw = std::make_unique(IoWatcher{fd, NonOwningSocket(fd), std::move(handler), events}); + this->pollSet->add(iw->socket, toPocoMode(events)); + this->ioWatchers.emplace(fd, std::move(iw)); +} + +void Reactor::applyMod(int fd, ReactorEvent events) { + auto it = this->ioWatchers.find(fd); + if (it == this->ioWatchers.end()) { + return; + } + IoWatcher &iw = *it->second; + if (iw.events == events) { + return; + } + iw.events = events; + // update 为非累积语义:直接覆盖为新的兴趣集(与原 mod 语义一致)。 + this->pollSet->update(iw.socket, toPocoMode(events)); +} + +void Reactor::applyDel(int fd) { + auto it = this->ioWatchers.find(fd); + if (it == this->ioWatchers.end()) { + return; + } + this->pollSet->remove(it->second->socket); + // erase 会析构 unique_ptr,自动释放,无需手动 delete。 + this->ioWatchers.erase(it); +} + +// ===================== 跨线程任务投递 ===================== + +void Reactor::post(Task task) { + { + std::unique_lock lock(this->taskMutex); + this->tasks.push(std::move(task)); + } + if (this->pollSet) { + this->pollSet->wakeUp(); + } +} + +void Reactor::drainTasks() { + // swap 批量取出,持锁时间极短;锁外执行,允许 task 内再次 post(不会自死锁)。 + std::queue pending; + { + std::unique_lock lock(this->taskMutex); + std::swap(pending, this->tasks); + } + while (!pending.empty()) { + pending.front()(); + pending.pop(); + } +} + +void Reactor::loop() { + candy::logger().debug("start thread: netstack reactor"); + // poll 超时仅作兜底;跨线程 post/stop 均通过 wakeUp 立即唤醒,不依赖该超时。 + const Poco::Timespan timeout(1, 0); // 1s + while (this->running.load()) { + Poco::Net::PollSet::SocketModeMap ready = this->pollSet->poll(timeout); + for (const auto &[socket, mode] : ready) { + int fd = static_cast(socket.impl()->sockfd()); + auto it = this->ioWatchers.find(fd); + if (it == this->ioWatchers.end()) { + continue; + } + // 关键:先把 handler 拷贝到局部,再调用。handler 内部可能 del(fd) 释放本 IoWatcher, + // 拷贝后调用可避免 use-after-free;回调返回前不再触碰 it/IoWatcher。 + Reactor::EventHandler handler = it->second->handler; + handler(fromPocoMode(mode)); + } + drainTasks(); + } + candy::logger().debug("stop thread: netstack reactor"); +} + +} // namespace candy diff --git a/candy/src/netstack/reactor.h b/candy/src/netstack/reactor.h new file mode 100644 index 00000000..daed66e8 --- /dev/null +++ b/candy/src/netstack/reactor.h @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_REACTOR_H +#define CANDY_NETSTACK_REACTOR_H + +#include +#include +#include +#include +#include +#include +#include +#include + +// Poco::Net::PollSet 的前向声明,避免在头文件暴露 (含 winsock)。 +namespace Poco { +namespace Net { +class PollSet; +} +} // namespace Poco + +namespace candy { + +// IoWatcher 定义于 .cc:内含非拥有的 Poco::Net::Socket、fd 与事件回调。 +// 此处仅前向声明,头文件不暴露 Poco 类型。 +struct IoWatcher; + +enum class ReactorEvent : uint32_t { + NONE = 0, + READ = 1 << 0, + WRITE = 1 << 1, + FAILURE = 1 << 2, +}; + +inline ReactorEvent operator|(ReactorEvent a, ReactorEvent b) { + return static_cast(static_cast(a) | static_cast(b)); +} + +inline bool operator&(ReactorEvent a, ReactorEvent b) { + return (static_cast(a) & static_cast(b)) != 0; +} + +// Reactor:落地 fd 的事件循环。基于 Poco::Net::PollSet(一份代码全平台:Linux=epoll、 +// macOS/BSD=poll、Windows=wepoll,select 兜底),不再手写各平台后端。管理所有落地 fd(内核 +// socket)的可读/可写/错误事件,非阻塞、事件回调驱动。 +// +// 线程契约: +// - 事件循环跑在 reactor 自己的线程;PollSet 的 add/update/remove 自带内部锁,可跨线程调用, +// 但为与原有语义一致并保证回调时序,仍统一在 reactor 线程内实际应用。 +// - 跨线程提交任务用 post():入队后 PollSet::wakeUp 唤醒 reactor 线程,由其在下一轮 poll 后 +// 抽干执行。wakeUp 通过 eventfd(Linux)/pipe(BSD) 实现,线程安全且不丢唤醒。 +// - add/mod/del 对 PollSet 的实际操作必须在 reactor 线程执行:若调用方已在 reactor 线程 +// (事件回调内)则直接应用;否则经 post 投递到 reactor 线程应用,保证 fd->handler 表一致。 +class Reactor { +public: + using EventHandler = std::function; + using Task = std::function; + + Reactor(); + ~Reactor(); + + int start(); + void stop(); + + int add(int fd, ReactorEvent events, EventHandler handler); + int mod(int fd, ReactorEvent events); + int del(int fd); + + void post(Task task); + +private: + void loop(); + void drainTasks(); + + bool onLoopThread() const; + // 以下三个 apply* 只在 reactor 线程执行,直接操作 PollSet 与 ioWatchers 表。 + void applyAdd(int fd, ReactorEvent events, EventHandler handler); + void applyMod(int fd, ReactorEvent events); + void applyDel(int fd); + + std::unique_ptr pollSet; + // fd -> IoWatcher(内含非拥有的 Poco::Net::Socket 与事件回调)。仅 reactor 线程访问,无需加锁。 + std::unordered_map> ioWatchers; + + std::thread thread; + std::atomic running; + std::thread::id loopThreadId; + + std::mutex taskMutex; + std::queue tasks; +}; + +} // namespace candy + +#endif diff --git a/candy/src/netstack/session.cc b/candy/src/netstack/session.cc new file mode 100644 index 00000000..bcdae316 --- /dev/null +++ b/candy/src/netstack/session.cc @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +#include "netstack/session.h" + +namespace candy { + +Session::Session(NetStack *stack) : stack(stack), closing(false) {} + +} // namespace candy diff --git a/candy/src/netstack/session.h b/candy/src/netstack/session.h new file mode 100644 index 00000000..3730a095 --- /dev/null +++ b/candy/src/netstack/session.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_SESSION_H +#define CANDY_NETSTACK_SESSION_H + +#include +#include + +namespace candy { + +class NetStack; + +// Session 基类:会话生命周期的公共抽象,派生出 TCP/UDP 两类会话。 +class Session { +public: + explicit Session(NetStack *stack); + virtual ~Session() = default; + + // 启动落地:建立内核 socket、发起连接、注册到 reactor。 + virtual int start() = 0; + +protected: + NetStack *stack; + std::atomic closing; +}; + +} // namespace candy + +#endif diff --git a/candy/src/netstack/session_tcp.cc b/candy/src/netstack/session_tcp.cc new file mode 100644 index 00000000..4c50d16f --- /dev/null +++ b/candy/src/netstack/session_tcp.cc @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: MIT +#include "netstack/session_tcp.h" +#include "netstack/netstack.h" +#include "netstack/sockcompat.h" +#include "utils/log.h" +#include +#include +#include + +#include "lwip/pbuf.h" +#include "lwip/tcp.h" + +#if defined(__linux__) || defined(__APPLE__) +#include +#include +#elif defined(_WIN32) || defined(_WIN64) +#include +#endif + +namespace candy { + +// fd -> lwIP 方向背压水位:积压达到 HIGH_WATER 暂停从落地 fd 读取,回落到 LOW_WATER 恢复。 +// 取值需高于 lwIP 单条 TCP 发送缓冲(TCP_SND_BUF≈46KB),留出在途 + 任务队列余量。 +static constexpr size_t HIGH_WATER = 256 * 1024; +static constexpr size_t LOW_WATER = 64 * 1024; + +SessionTcp::SessionTcp(NetStack *stack, struct tcp_pcb *pcb) + : Session(stack), pcb(pcb), fd(-1), connected(false), origDstPort(0), wantWrite(false), fdEof(false) { + std::memcpy(&this->origDst, &ip_2_ip4(&pcb->local_ip)->addr, sizeof(uint32_t)); + this->origDstPort = pcb->local_port; + std::memcpy(&this->origSrc, &ip_2_ip4(&pcb->remote_ip)->addr, sizeof(uint32_t)); +} + +SessionTcp::~SessionTcp() { + if (this->fd >= 0) { + netClose(this->fd); + this->fd = -1; + } +} + +int SessionTcp::start() { + this->self = shared_from_this(); + + tcp_arg(this->pcb, this); + tcp_recv(this->pcb, recvTrampoline); + tcp_sent(this->pcb, sentTrampoline); + tcp_err(this->pcb, errTrampoline); + +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + // 落地拨号:内核 socket 直连业务目的(DirectOutbound)。 + // 返回已发起 connect(EINPROGRESS 视为成功)的非阻塞 fd。 + Outbound &outbound = this->stack->getOutbound(); + this->fd = outbound.dialTcp(Endpoint{this->origDst, this->origDstPort}); + if (this->fd < 0) { + return -1; + } + + candy::logger().debug( + Poco::format("session tcp: %s -> %s:%hu", this->origSrc.toString(), this->origDst.toString(), this->origDstPort)); + + auto holder = shared_from_this(); + int fd = this->fd; + this->stack->getReactor().add(fd, ReactorEvent::WRITE, [holder](ReactorEvent ev) { holder->onFdEvent((uint32_t)ev); }); + return 0; +#else + return -1; +#endif +} + +// ===================== NetStack 线程 ===================== + +err_t SessionTcp::recvTrampoline(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { + SessionTcp *self = (SessionTcp *)arg; + return self->onRecv(pcb, p, err); +} + +err_t SessionTcp::sentTrampoline(void *arg, struct tcp_pcb *pcb, u16_t len) { + SessionTcp *self = (SessionTcp *)arg; + return self->onSent(pcb, len); +} + +void SessionTcp::errTrampoline(void *arg, err_t err) { + SessionTcp *self = (SessionTcp *)arg; + self->onErr(err); +} + +err_t SessionTcp::onRecv(struct tcp_pcb *pcb, struct pbuf *p, err_t err) { + if (p == nullptr || err != ERR_OK) { + // 对端(lwIP 侧)关闭:通知 reactor 关闭落地 fd + if (p != nullptr) { + pbuf_free(p); + } + closeFromStack(); + return ERR_OK; + } + + std::string data; + data.resize(p->tot_len); + pbuf_copy_partial(p, data.data(), p->tot_len, 0); + u16_t recvLen = p->tot_len; + pbuf_free(p); + + size_t bufSize; + { + std::unique_lock lock(this->bufMutex); + this->forwardBuf.append(data); + bufSize = this->forwardBuf.size(); + } + // 注意:不在此处 tcp_recved!接收窗口的推进延迟到数据真正写进落地 fd 之后 + // (flushForwardLocked 累加 pendingRecved -> NetStack 线程 ackRecvedToLwip)。 + // 否则会向源端谎报"已收下",落地端写得慢时 forwardBuf 无界膨胀直至 OOM。 + + // 背压埋点(lwIP->fd):记录本次收量、forwardBuf 积压、当前未确认窗口(pendingRecved)。 + // forwardBuf 应稳定在一个 TCP_WND 内;若持续增长说明落地端写不动、背压失效。 + candy::logger().debug(Poco::format("[bp][fwd] onRecv %s -> %s:%hu recv=%hu forwardBuf=%zu pendingRecved=%zu sndwnd=%d", + this->origSrc.toString(), this->origDst.toString(), this->origDstPort, recvLen, bufSize, + this->pendingRecved.load(), (int)(this->pcb ? tcp_sndbuf(this->pcb) : 0))); + + auto holder = shared_from_this(); + this->stack->getReactor().post([holder] { holder->flushForwardLocked(); }); + return ERR_OK; +} + +err_t SessionTcp::onSent(struct tcp_pcb *pcb, u16_t len) { + // sndbuf 释放了空间,继续把待发缓冲灌进 lwIP。 + pumpToLwip(); + return ERR_OK; +} + +void SessionTcp::onErr(err_t err) { + // pcb 已被 lwIP 释放(RST/abort 路径,不会再有 onRecv(p==null) 走正常关闭)。 + // 必须在此显式从会话表移除:否则 sessions[pcb] 仍持有一份 shared_ptr, + // 而 closeFromReactor 因 pcb 已空不会触发 closeFromStack -> removeSession, + // 导致 SessionTcp(连同 forwardBuf/backwardBuf)泄漏至下次重连 teardown。 + struct tcp_pcb *deadPcb = this->pcb; + this->pcb = nullptr; + if (deadPcb != nullptr) { + this->stack->removeSession(deadPcb); + } + closeFromReactor(); +} + +void SessionTcp::writeToLwip(const std::string &data) { + if (this->pcb == nullptr) { + return; + } + this->backwardBuf.append(data); + pumpToLwip(); +} + +void SessionTcp::pumpToLwip() { + // 仅在 NetStack 线程调用。把 backwardBuf 尽量写入 lwIP 发送缓冲, + // 写不下的保留,等 onSent 回调再续;并据缓冲水位对落地 fd 读做背压。 + if (this->pcb == nullptr) { + return; + } + size_t offset = 0; + bool written = false; + while (offset < this->backwardBuf.size()) { + u16_t space = tcp_sndbuf(this->pcb); + if (space == 0) { + break; + } + u16_t chunk = (u16_t)std::min((size_t)space, this->backwardBuf.size() - offset); + err_t e = tcp_write(this->pcb, this->backwardBuf.data() + offset, chunk, TCP_WRITE_FLAG_COPY); + if (e == ERR_MEM) { + break; + } + if (e != ERR_OK) { + return; + } + offset += chunk; + written = true; + } + if (offset > 0) { + this->backwardBuf.erase(0, offset); + // 这部分已真正进入 lwIP 发送缓冲,从积压中扣除。 + size_t prev = this->backwardBytes.fetch_sub(offset); + (void)prev; + } + if (written) { + tcp_output(this->pcb); + } + updateReadInterest(); + + // 落地端已 EOF 且待发缓冲已排空:此时才能安全关闭 lwIP 侧, + // 否则会丢掉尾部数据(之前 ~80% 处 connection closed prematurely 的根因)。 + if (this->fdEof && this->backwardBuf.empty()) { + closeFromStack(); + } +} + +void SessionTcp::onFdEofFromStack() { + this->fdEof = true; + // 尝试把残留数据灌完;若已空则 pumpToLwip 内部会触发 closeFromStack。 + pumpToLwip(); + if (this->backwardBuf.empty()) { + closeFromStack(); + } +} + +void SessionTcp::ackRecvedToLwip() { + // 仅 NetStack 线程调用。把已写进落地 fd 的字节向 lwIP 确认,推进接收窗口。 + // 多个 flush 的确认会被合并在一次调用里消费(exchange 取走累计值)。 + if (this->pcb == nullptr) { + return; + } + size_t acked = this->pendingRecved.exchange(0); + if (acked == 0) { + return; + } + // tcp_recved 的 len 是 u16_t,分批确认避免溢出。 + while (acked > 0) { + u16_t chunk = (u16_t)std::min(acked, (size_t)0xFFFF); + tcp_recved(this->pcb, chunk); + acked -= chunk; + } +} + +void SessionTcp::updateReadInterest() { + // 背压恢复(NetStack 线程):积压回落到低水位且当前处于暂停态时,恢复从落地 fd 读取。 + // 暂停动作在 Reactor 线程 onFdReadable 内同步完成(读到高水位即停),二者形成闭环: + // Reactor 读出 -> backwardBytes += -> 到高水位停读 + // NetStack pump 进 lwIP -> backwardBytes -= -> 到低水位恢复读 + // 如此 fd 读取速率被 lwIP 发送+ACK 回收节奏严格约束,不再淹没单线程 NetStack。 + if (this->readPaused.load() && this->backwardBytes.load() <= LOW_WATER) { + this->readPaused.store(false); + // 背压埋点(fd->lwIP):积压回落到低水位,恢复读取(水位下降沿,低频事件)。 + candy::logger().debug(Poco::format("[bp][bwd] resume read %s:%hu -> %s backwardBytes=%zu (<=LOW_WATER=%zu)", + this->origDst.toString(), this->origDstPort, this->origSrc.toString(), + this->backwardBytes.load(), LOW_WATER)); + int fd = this->fd; + auto holder = shared_from_this(); + this->stack->getReactor().post([holder, fd] { + if (fd >= 0 && !holder->closing.load()) { + holder->stack->getReactor().mod(fd, ReactorEvent::READ); + } + }); + } +} + +void SessionTcp::closeFromStack() { + if (this->pcb != nullptr) { + tcp_arg(this->pcb, nullptr); + tcp_recv(this->pcb, nullptr); + tcp_sent(this->pcb, nullptr); + tcp_err(this->pcb, nullptr); + if (tcp_close(this->pcb) != ERR_OK) { + tcp_abort(this->pcb); + } + } + struct tcp_pcb *closedPcb = this->pcb; + this->pcb = nullptr; + + NetStack *stack = this->stack; + auto holder = shared_from_this(); + stack->getReactor().post([holder] { holder->closeFromReactor(); }); + if (closedPcb != nullptr) { + stack->removeSession(closedPcb); + } +} + +void SessionTcp::shutdownFromStack() { + // 栈拆除路径:在 NetStack 线程内直接 abort pcb(此时 netif 即将移除, + // 不能再依赖正常 tcp_close 的四次挥手)。fd 交由 reactor 关闭。 + if (this->pcb != nullptr) { + tcp_arg(this->pcb, nullptr); + tcp_recv(this->pcb, nullptr); + tcp_sent(this->pcb, nullptr); + tcp_err(this->pcb, nullptr); + tcp_abort(this->pcb); + this->pcb = nullptr; + } + if (!this->closing.exchange(true)) { + auto holder = shared_from_this(); + this->stack->getReactor().post([holder] { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (holder->fd >= 0) { + holder->stack->getReactor().del(holder->fd); + netClose(holder->fd); + holder->fd = -1; + } +#endif + holder->self.reset(); + }); + } +} + +// ===================== Reactor 线程 ===================== + +void SessionTcp::onFdEvent(uint32_t events) { + ReactorEvent ev = (ReactorEvent)events; + if (!this->connected) { + if (ev & ReactorEvent::FAILURE) { + closeFromReactor(); + return; + } + if (ev & ReactorEvent::WRITE) { + onConnected(); + } + return; + } + // 已连接:优先把可读数据/EOF 读干净(read 返回 0 走优雅排空,返回 -1 走关闭), + // 避免 ERROR/HUP 与可读数据同时到达时抢先关闭而丢失尾部数据。 + if (ev & (ReactorEvent::READ | ReactorEvent::FAILURE)) { + onFdReadable(); + } + if (ev & ReactorEvent::WRITE) { + onFdWritable(); + } +} + +void SessionTcp::onConnected() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + int err = 0; +#if defined(_WIN32) || defined(_WIN64) + int len = sizeof(err); + int ret = ::getsockopt((SOCKET)this->fd, SOL_SOCKET, SO_ERROR, (char *)&err, &len); +#else + socklen_t len = sizeof(err); + int ret = ::getsockopt(this->fd, SOL_SOCKET, SO_ERROR, &err, &len); +#endif + if (ret != 0 || err != 0) { + candy::logger().warning(Poco::format("session tcp connect result error: %s", netErrStr(err ? err : netLastError()))); + closeFromReactor(); + return; + } + this->connected = true; + this->stack->getReactor().mod(this->fd, ReactorEvent::READ); + flushForwardLocked(); +#endif +} + +void SessionTcp::flushForwardLocked() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (this->fd < 0 || !this->connected) { + return; + } + size_t acked = 0; + size_t remain = 0; + bool blocked = false; + { + std::unique_lock lock(this->bufMutex); + while (!this->forwardBuf.empty()) { + long n = netSend(this->fd, this->forwardBuf.data(), this->forwardBuf.size()); + if (n > 0) { + this->forwardBuf.erase(0, n); + acked += (size_t)n; + continue; + } + if (n < 0 && netWouldBlock(netLastError())) { + this->wantWrite = true; + this->stack->getReactor().mod(this->fd, ReactorEvent::READ | ReactorEvent::WRITE); + blocked = true; + break; + } + lock.unlock(); + closeFromReactor(); + return; + } + if (this->forwardBuf.empty() && this->wantWrite) { + this->wantWrite = false; + this->stack->getReactor().mod(this->fd, ReactorEvent::READ); + } + remain = this->forwardBuf.size(); + } + // 背压埋点(lwIP->fd):记录本次写进落地 fd 的字节、剩余积压、是否因 fd 写满而背压。 + // blocked=true 表示落地端写满(EAGAIN),forwardBuf 残留并等待 onFdWritable 续写; + // 此时不向 lwIP 确认残留部分,源端接收窗口收紧 -> 自动减速,这正是背压生效的体现。 + candy::logger().debug(Poco::format("[bp][fwd] flush %s -> %s:%hu wrote=%zu remain=%zu blocked=%d pendingRecved->%zu", + this->origSrc.toString(), this->origDst.toString(), this->origDstPort, acked, remain, + (int)blocked, this->pendingRecved.load() + acked)); + // 已落地的字节交由 NetStack 线程向 lwIP 确认(推进接收窗口)。 + // 这才是真正的"收到",从而把源端发送速率约束在落地 fd 的消费能力之内。 + if (acked > 0) { + this->pendingRecved.fetch_add(acked); + auto holder = shared_from_this(); + this->stack->postToStack([holder] { holder->ackRecvedToLwip(); }); + } +#endif +} + +void SessionTcp::onFdWritable() { + flushForwardLocked(); +} + +void SessionTcp::onFdReadable() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + char buf[65536]; + while (true) { + // 同步背压:积压超过高水位立即停读,避免单线程 NetStack 被 writeToLwip 任务淹没。 + // 恢复由 NetStack 线程在 pumpToLwip(onSent 驱动)排空到低水位后触发。 + if (this->backwardBytes.load() >= HIGH_WATER) { + this->readPaused.store(true); + // 背压埋点(fd->lwIP):积压达到高水位,停止读取(水位上升沿,低频事件)。 + candy::logger().debug(Poco::format("[bp][bwd] pause read %s:%hu -> %s backwardBytes=%zu (>=HIGH_WATER=%zu)", + this->origDst.toString(), this->origDstPort, this->origSrc.toString(), + this->backwardBytes.load(), HIGH_WATER)); + this->stack->getReactor().mod(this->fd, ReactorEvent::NONE); + return; + } + long n = netRecv(this->fd, buf, sizeof(buf)); + if (n > 0) { + this->backwardBytes.fetch_add((size_t)n); + std::string data(buf, n); + auto holder = shared_from_this(); + this->stack->postToStack([holder, data = std::move(data)]() mutable { holder->writeToLwip(data); }); + continue; + } + if (n == 0) { + // 落地端关闭:等 backwardBuf 灌完再关 lwIP 侧,避免尾部数据丢失。 + auto holder = shared_from_this(); + this->stack->postToStack([holder] { holder->onFdEofFromStack(); }); + this->stack->getReactor().del(this->fd); + return; + } + if (netWouldBlock(netLastError())) { + return; + } + closeFromReactor(); + return; + } +#endif +} + +void SessionTcp::closeFromReactor() { + if (this->closing.exchange(true)) { + return; + } +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (this->fd >= 0) { + this->stack->getReactor().del(this->fd); + netClose(this->fd); + this->fd = -1; + } +#endif + if (this->pcb != nullptr) { + auto holder = shared_from_this(); + this->stack->postToStack([holder] { holder->closeFromStack(); }); + } + // 释放自持引用(最后一个引用消失时析构) + auto keep = this->self; + this->self.reset(); +} + +} // namespace candy diff --git a/candy/src/netstack/session_tcp.h b/candy/src/netstack/session_tcp.h new file mode 100644 index 00000000..128f2217 --- /dev/null +++ b/candy/src/netstack/session_tcp.h @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_SESSION_TCP_H +#define CANDY_NETSTACK_SESSION_TCP_H + +#include "core/net.h" +#include "netstack/session.h" +#include +#include +#include +#include +#include + +// Windows:在 lwIP 头之前引入 winsock(见 netstack.h 说明),避免 htonl 宏改写导致声明冲突。 +#include "netstack/sockcompat.h" + +#include "lwip/tcp.h" + +namespace candy { + +// SessionTcp:一条被 lwIP 终结的 TCP 连接对应一个内核 socket 落地。 +// +// 线程契约: +// - 所有 tcp_*(pcb) 调用只在 NetStack 线程执行; +// - 所有 fd 的 read/write 只在 Reactor 线程执行; +// - 两侧通过 NetStack::postToStack / Reactor::post 投递任务跨线程交互。 +// +// 数据流: +// 正向 lwIP->fd:tcp_recv 回调(NetStack线程) 收数据 -> 缓存 -> 投递 reactor 在可写时 write(fd) +// 反向 fd->lwIP:fd 可读(reactor线程) read -> 投递 NetStack 执行 tcp_write + tcp_output +class SessionTcp : public Session, public std::enable_shared_from_this { +public: + SessionTcp(NetStack *stack, struct tcp_pcb *pcb); + ~SessionTcp() override; + + int start() override; + + // 仅供 NetStack 线程在栈拆除(重连)时调用,强制关闭 pcb 与 fd。 + void shutdownFromStack(); + +private: + // ---- NetStack 线程 ---- + static err_t recvTrampoline(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err); + static err_t sentTrampoline(void *arg, struct tcp_pcb *pcb, u16_t len); + static void errTrampoline(void *arg, err_t err); + + err_t onRecv(struct tcp_pcb *pcb, struct pbuf *p, err_t err); + err_t onSent(struct tcp_pcb *pcb, u16_t len); + void onErr(err_t err); + + void writeToLwip(const std::string &data); + void pumpToLwip(); + void onFdEofFromStack(); + void closeFromStack(); + // 把已写进落地 fd 的字节数向 lwIP 确认(推进接收窗口)。仅 NetStack 线程调用。 + void ackRecvedToLwip(); + + // ---- Reactor 线程 ---- + void onFdEvent(uint32_t events); + void onConnected(); + void onFdReadable(); + void onFdWritable(); + void closeFromReactor(); + + void flushForwardLocked(); + void updateReadInterest(); + +private: + struct tcp_pcb *pcb; + int fd; + bool connected; + + IP4 origDst; + uint16_t origDstPort; + IP4 origSrc; + + std::mutex bufMutex; + std::string forwardBuf; + bool wantWrite; + + // lwIP -> fd 方向背压核心:已写进落地 fd、待向 lwIP 确认(tcp_recved)的字节数。 + // 由 Reactor 线程累加、NetStack 线程在 ackRecvedToLwip 中消费,跨线程用原子传递。 + // 延迟确认使 lwIP 接收窗口只在数据真正落地后才推进,从而把源端发送速率自动约束在 + // 落地 fd 的消费能力之内,forwardBuf 天然被限制在一个 TCP_WND 内,杜绝无界膨胀。 + std::atomic pendingRecved{0}; + + // fd -> lwIP 方向待发缓冲(仅 NetStack 线程访问)。 + // tcp_sndbuf 满时暂存,onSent 释放空间后继续 pump,避免静默丢包。 + std::string backwardBuf; + std::atomic readPaused{false}; + bool fdEof; + // fd -> lwIP 方向"已从落地 fd 读出但尚未被 lwIP 确认排空"的字节积压。 + // 由 Reactor 线程(读出后 +)与 NetStack 线程(pump 写入 lwIP 后 -)共同维护, + // 用于在 Reactor 读取侧做同步背压:超过高水位立即停读,回落到低水位再恢复, + // 避免单线程 NetStack 被海量 writeToLwip 任务淹没、导致回程 ACK 饿死、发送窗口卡死。 + std::atomic backwardBytes{0}; + + std::shared_ptr self; +}; + +} // namespace candy + +#endif diff --git a/candy/src/netstack/session_udp.cc b/candy/src/netstack/session_udp.cc new file mode 100644 index 00000000..d7a068dc --- /dev/null +++ b/candy/src/netstack/session_udp.cc @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT +#include "netstack/session_udp.h" +#include "netstack/netstack.h" +#include "netstack/sockcompat.h" +#include "utils/log.h" +#include +#include + +#include "lwip/pbuf.h" +#include "lwip/udp.h" + +#if defined(__linux__) || defined(__APPLE__) +#include +#include +#elif defined(_WIN32) || defined(_WIN64) +#include +#endif + +namespace candy { + +SessionUdp::SessionUdp(NetStack *stack, struct udp_pcb *pcb, IP4 origSrc, uint16_t origSrcPort, bool converged) + : Session(stack), pcb(pcb), fd(-1), converged(converged), origSrc(origSrc), origSrcPort(origSrcPort), + lastActiveTs(std::chrono::steady_clock::now()) { + // 源二元组 key:源IP:源端口(一源一会话,目的 per-packet 不入 key) + this->sessionKey.assign((const char *)&origSrc, sizeof(uint32_t)); + this->sessionKey.append((const char *)&origSrcPort, sizeof(origSrcPort)); +} + +SessionUdp::~SessionUdp() { + if (this->fd >= 0) { + netClose(this->fd); + this->fd = -1; + } +} + +int SessionUdp::start() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + this->self = shared_from_this(); + + if (this->converged) { + // 单端口收敛模式:不自建 fd。发送经全局 UdpMux 的单 fd、回程由 UdpMux demux 后回调 + // replyToLwip。本会话只保留 npcb(作 innerNpcb 定位表项),注册每流 recv 即可。 + candy::logger().debug( + Poco::format("session udp (converged) src: %s:%hu", this->origSrc.toString(), this->origSrcPort)); + udp_recv(this->pcb, recvTrampoline, this); + return 0; + } + + // 每源全锥形模式:本会话自建 unconnected + 固定出口端口的落地 fd,自 recvfrom 收回包。 + Outbound &outbound = this->stack->getOutbound(); + this->fd = outbound.dialUdp(); + if (this->fd < 0) { + return -1; + } + + candy::logger().debug(Poco::format("session udp src: %s:%hu", this->origSrc.toString(), this->origSrcPort)); + + auto holder = shared_from_this(); + int fd = this->fd; + this->stack->getReactor().add(fd, ReactorEvent::READ, [holder](ReactorEvent ev) { holder->onFdEvent((uint32_t)ev); }); + + // 在 lwIP 克隆出的 npcb 上注册每流 recv:本流后续数据报都回调到 recvTrampoline。 + // recv_arg 用裸指针 this:会话存活期间被 udpSessions 持有 + self 自持,指针有效。 + udp_recv(this->pcb, recvTrampoline, this); + return 0; +#else + return -1; +#endif +} + +// ===================== NetStack 线程 ===================== + +void SessionUdp::recvTrampoline(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + SessionUdp *self = (SessionUdp *)arg; + self->onPcbRecv(p, addr, port); +} + +void SessionUdp::onPcbRecv(struct pbuf *p, const ip_addr_t *addr, u16_t port) { + // 本流后续数据报:addr/port=源端(dev1)。目的由 lwIP 在回调前覆写进 pcb->local_*, + // 必须此刻同步读取(下一入站包会再次覆写),随数据一并按值投递到 reactor。 + if (p == nullptr) { + return; + } + uint32_t dstIpBe; + std::memcpy(&dstIpBe, &ip_2_ip4(&this->pcb->local_ip)->addr, sizeof(uint32_t)); + uint16_t dstPortHost = this->pcb->local_port; // lwIP 端口为主机序 + std::string data; + data.resize(p->tot_len); + pbuf_copy_partial(p, data.data(), p->tot_len, 0); + pbuf_free(p); + this->lastActiveTs = std::chrono::steady_clock::now(); + if (this->converged) { + // 收敛模式:发送经全局 UdpMux(内部做 STUN 识别/txid 登记/数据面 FCFS 锁,通过后 post + // 到 Reactor 用全局 fd 发送)。表操作全在 NetStack 线程完成,符合 §11.3a 线程归属。 + this->stack->sendUdpConverged(this->sessionKey, dstIpBe, dstPortHost, std::move(data)); + return; + } + sendToLanding(dstIpBe, dstPortHost, std::move(data)); +} + +void SessionUdp::sendToLanding(uint32_t dstIpBe, uint16_t dstPortHost, std::string data) { + // 仅 NetStack 线程调用:刷新活跃时间,把数据报连同目的投递到 reactor 线程用 sendto 发送。 + this->lastActiveTs = std::chrono::steady_clock::now(); + auto holder = shared_from_this(); + this->stack->getReactor().post([holder, dstIpBe, dstPortHost, data = std::move(data)]() mutable { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (holder->closing.load()) { + return; + } + if (holder->fd < 0) { + return; + } + long n = netSendTo(holder->fd, data.data(), data.size(), dstIpBe, dstPortHost); + if (n < 0 && !netWouldBlock(netLastError())) { + candy::logger().debug(Poco::format("session udp sendto failed: %s", netErrStr(netLastError()))); + } +#endif + }); +} + +void SessionUdp::replyToLwip(uint32_t peerIpBe, uint16_t peerPortHost, std::string data) { + // 仅 NetStack 线程调用:把落地回包以 (实际对端 -> origSrc:origSrcPort) 重新注入 lwIP, + // 由 netif->output 产生内层 IP 包、再经 NetStack::output 封 IPIP 回源端。 + // 全锥形:回注源用「实际对端」(可为 P2P 对端/STUN),而非固定落地目的,支撑打洞回包。 + if (this->pcb == nullptr) { + return; + } + this->lastActiveTs = std::chrono::steady_clock::now(); + + struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, (u16_t)data.size(), PBUF_RAM); + if (p == nullptr) { + candy::logger().warning("session udp reply pbuf_alloc failed"); + return; + } + pbuf_take(p, data.data(), (u16_t)data.size()); + + // udp_sendto_if_src 的 UDP 源端口取自 pcb->local_port(非参数),故注入前先写为对端端口。 + // pretend pcb 的 local_* 不参与入站匹配(只认 remote_*),且下一正向包会被 lwIP 再次覆写,安全。 + this->pcb->local_port = peerPortHost; + + // 源地址=实际对端(peerIp:peerPort);目的=源端(origSrc:origSrcPort)。 + ip_addr_t src; + ip_addr_t dst; + ip_addr_set_ip4_u32(&src, peerIpBe); + ip_addr_set_ip4_u32(&dst, uint32_t(this->origSrc)); + err_t e = udp_sendto_if_src(this->pcb, p, &dst, this->origSrcPort, &this->stack->getNetif(), &src); + if (e != ERR_OK) { + candy::logger().debug(Poco::format("session udp udp_sendto_if_src failed: %d", (int)e)); + } + pbuf_free(p); +} + +void SessionUdp::closeFromStack() { + if (this->pcb != nullptr) { + udp_recv(this->pcb, nullptr, nullptr); + udp_remove(this->pcb); + this->pcb = nullptr; + } + NetStack *stack = this->stack; + std::string k = this->sessionKey; + // 收敛模式:本源整体消亡,通知 UdpMux 级联清掉该源名下全部 endpointOwner 条目(§11.3 兜底)。 + if (this->converged) { + stack->onUdpSourceGone(k); + } + auto holder = shared_from_this(); + stack->getReactor().post([holder] { holder->closeFromReactor(); }); + stack->removeUdpSession(k); +} + +void SessionUdp::shutdownFromStack() { + // 栈拆除路径:NetStack 线程内直接移除 pcb;fd 交由 reactor 关闭。 + if (this->pcb != nullptr) { + udp_recv(this->pcb, nullptr, nullptr); + udp_remove(this->pcb); + this->pcb = nullptr; + } + // 收敛模式:整栈拆除时 UdpMux 会被 NetStack 统一 shutdown 清表,故此处无需逐源级联。 + if (!this->closing.exchange(true)) { + auto holder = shared_from_this(); + this->stack->getReactor().post([holder] { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (holder->fd >= 0) { + holder->stack->getReactor().del(holder->fd); + netClose(holder->fd); + holder->fd = -1; + } +#endif + holder->self.reset(); + }); + } +} + +// ===================== Reactor 线程 ===================== + +void SessionUdp::onFdEvent(uint32_t events) { + ReactorEvent ev = (ReactorEvent)events; + if (ev & ReactorEvent::FAILURE) { + closeFromReactor(); + return; + } + if (ev & ReactorEvent::READ) { + onFdReadable(); + } +} + +void SessionUdp::onFdReadable() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + char buf[65536]; + while (true) { + // 全锥形:unconnected socket 用 recvfrom 收任意对端回包,同时取回真实对端地址/端口, + // 连同数据投递到 NetStack 线程,由 replyToLwip 以该对端为源注入回 dev1(支撑打洞回包)。 + uint32_t peerIpBe; + uint16_t peerPortHost; + long n = netRecvFrom(this->fd, buf, sizeof(buf), &peerIpBe, &peerPortHost); + if (n > 0) { + std::string data(buf, n); + auto holder = shared_from_this(); + this->stack->postToStack([holder, peerIpBe, peerPortHost, data = std::move(data)]() mutable { + holder->replyToLwip(peerIpBe, peerPortHost, std::move(data)); + }); + continue; + } + if (n == 0) { + return; + } + if (netWouldBlock(netLastError())) { + return; + } + // 落地 socket 错误:unconnected 下罕见(对称型 connect socket 才会经 ICMP 收到 + // ECONNREFUSED),保留关闭分支兜底,空闲回收主要交由 reapIdleUdpSessions。 + closeFromReactor(); + return; + } +#endif +} + +void SessionUdp::closeFromReactor() { + if (this->closing.exchange(true)) { + return; + } +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (this->fd >= 0) { + this->stack->getReactor().del(this->fd); + netClose(this->fd); + this->fd = -1; + } +#endif + if (this->pcb != nullptr) { + auto holder = shared_from_this(); + this->stack->postToStack([holder] { holder->closeFromStack(); }); + } + auto keep = this->self; + this->self.reset(); +} + +} // namespace candy diff --git a/candy/src/netstack/session_udp.h b/candy/src/netstack/session_udp.h new file mode 100644 index 00000000..4c00458c --- /dev/null +++ b/candy/src/netstack/session_udp.h @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_SESSION_UDP_H +#define CANDY_NETSTACK_SESSION_UDP_H + +#include "core/net.h" +#include "netstack/session.h" +#include +#include +#include +#include +#include + +// Windows:在 lwIP 头之前引入 winsock(见 netstack.h 说明),避免 htonl 宏改写导致声明冲突。 +#include "netstack/sockcompat.h" + +#include "lwip/udp.h" + +namespace candy { + +// SessionUdp:一条 UDP 源端点会话——把 lwIP 终结的某个源二元组 (源IP,源端口) 的全部 +// 出站数据流,映射到一个 unconnected 的内核 UDP socket 落地,实现全锥形(Full Cone)NAT。 +// +// 全锥形要点: +// - 落地 fd 不 connect 固定目的,bind 一个固定出口端口后用 sendto 发往任意多目的、 +// 用 recvfrom 收任意对端回包 → endpoint-independent mapping + filtering。 +// - lwIP 的 PRETEND netif 天然「一源一 npcb」:同一源二元组无论发往多少不同目的都命中 +// 同一 npcb,每个数据报的目的由 lwIP 覆写进 pcb->local_ip/local_port,故目的是 per-packet +// 从回调现场读取,不属于会话本体。 +// - 回包注入时用「实际对端」作源地址、并临时写 npcb->local_port 作 UDP 源端口,从而把来自 +// 陌生对端(P2P 对端 / STUN)的包反向注入回源端,支撑 UDP 打洞。 +// +// 线程契约(与 SessionTcp 一致): +// - 所有 udp_*(pcb) 调用只在 NetStack 线程执行; +// - 所有 fd 的 recv/send 只在 Reactor 线程执行; +// - 两侧通过 NetStack::postToStack / Reactor::post 投递任务跨线程交互。 +// +// 数据流: +// 正向 lwIP->fd:udp_recv 回调(NetStack线程) 收数据报 + 读 pcb->local_* 得目的 -> 投递 reactor sendto(fd) +// 反向 fd->lwIP:fd 可读(reactor线程) recvfrom 得真实对端 -> 投递 NetStack udp_sendto_if_src 回送源端 +class SessionUdp : public Session, public std::enable_shared_from_this { +public: + // pcb:lwIP 为该源二元组克隆出的 npcb(NetStack 线程持有,其 local_* 随每个入站包被 lwIP + // 覆写为当前目的)。origSrc/origSrcPort:源端(dev1)。 + // converged:是否单端口收敛模式。false=每源全锥形(本会话自建 fd、自 recvfrom); + // true=所有源共用全局 UdpMux 的单 fd(本会话不建 fd,发送经 UdpMux、回程由 UdpMux + // demux 后回调本会话 replyToLwip)。 + SessionUdp(NetStack *stack, struct udp_pcb *pcb, IP4 origSrc, uint16_t origSrcPort, bool converged); + ~SessionUdp() override; + + int start() override; + + // 仅供 NetStack 线程在栈拆除(重连)时调用,强制关闭 pcb 与 fd。 + void shutdownFromStack(); + + // NetStack 线程:把一份来自 lwIP 的数据报连同其目的(per-packet,从 pcb->local_* 读得) + // 投递到落地 fd,用 sendto 发往该目的。 + void sendToLanding(uint32_t dstIpBe, uint16_t dstPortHost, std::string data); + + // 落地 fd 收到某对端(peerIp:peerPort)回包后,用 pcb 以该对端为源把数据报注入回源端。 + // 每源模式:本会话 onFdReadable 内经 postToStack 自调; + // 收敛模式:UdpMux demux 出本源后经 NetStack::injectUdpReply 调用。均在 NetStack 线程。 + void replyToLwip(uint32_t peerIpBe, uint16_t peerPortHost, std::string data); + + // 该会话的源二元组 key(NetStack 线程的会话表用)。 + const std::string &key() const { + return this->sessionKey; + } + + // 最近活跃时间(用于空闲超时回收,仅 NetStack 线程访问)。 + std::chrono::steady_clock::time_point lastActive() const { + return this->lastActiveTs; + } + + // lwIP 每流 recv 跳板:克隆出的 npcb 后续同流数据报都走这里(仅 NetStack 线程)。 + static void recvTrampoline(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + +private: + // ---- Reactor 线程 ---- + void onFdEvent(uint32_t events); + void onFdReadable(); + void closeFromReactor(); + + // ---- NetStack 线程 ---- + // 本流后续数据报到达:addr/port=源端(dev1),目的从 pcb->local_* 读取,投递到落地 fd 发送。 + void onPcbRecv(struct pbuf *p, const ip_addr_t *addr, u16_t port); + void closeFromStack(); + +private: + struct udp_pcb *pcb; + int fd; + + // 单端口收敛模式:true=不自建 fd,发送经全局 UdpMux、回程由 UdpMux 回调 replyToLwip。 + bool converged; + + IP4 origSrc; + uint16_t origSrcPort; + + std::string sessionKey; + std::chrono::steady_clock::time_point lastActiveTs; + + std::shared_ptr self; +}; + +} // namespace candy + +#endif diff --git a/candy/src/netstack/sockcompat.h b/candy/src/netstack/sockcompat.h new file mode 100644 index 00000000..2a58a3a1 --- /dev/null +++ b/candy/src/netstack/sockcompat.h @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_SOCKCOMPAT_H +#define CANDY_NETSTACK_SOCKCOMPAT_H + +// 落地 socket 的跨平台薄封装:抹平 POSIX(Linux/macOS) 与 Winsock(Windows) 的差异, +// 使 session_tcp / session_udp 的落地收发逻辑三端共用同一份代码。 +// 约定:对外统一以 int 表示 socket(Windows SOCKET 为小整数句柄,转换安全)。 + +#include +#include + +#if defined(_WIN32) || defined(_WIN64) +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif +#include +#include +#else +#include // htons/ntohs(无连接收发的端口字节序转换) +#include // errno 是 C 标准库唯一入口,无 C++ 等价,属不得已保留。 +#include +#include // struct sockaddr_in / INADDR_ANY(无连接收发地址组装) +#include +#include +#endif + +namespace candy { + +// 关闭落地 socket。 +inline void netClose(int fd) { +#if defined(_WIN32) || defined(_WIN64) + ::closesocket((SOCKET)fd); +#else + ::close(fd); +#endif +} + +// 设为非阻塞。成功返回 0。 +inline int netSetNonBlocking(int fd) { +#if defined(_WIN32) || defined(_WIN64) + u_long on = 1; + return ::ioctlsocket((SOCKET)fd, FIONBIO, &on) == 0 ? 0 : -1; +#else + int flags = ::fcntl(fd, F_GETFL, 0); + return ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); +#endif +} + +// 收:返回读到字节数;0 表示对端关闭;<0 表示错误(含 would-block)。 +inline long netRecv(int fd, void *buf, size_t len) { +#if defined(_WIN32) || defined(_WIN64) + return ::recv((SOCKET)fd, (char *)buf, (int)len, 0); +#else + return ::recv(fd, buf, len, 0); +#endif +} + +// 发:返回写出字节数;<0 表示错误(含 would-block)。 +inline long netSend(int fd, const void *buf, size_t len) { +#if defined(_WIN32) || defined(_WIN64) + return ::send((SOCKET)fd, (const char *)buf, (int)len, 0); +#else + return ::send(fd, buf, len, 0); +#endif +} + +// 无连接发(全锥形出口用):目的地址(网络序 s_addr) + 目的端口(主机序)。 +// 返回写出字节数;<0 表示错误(含 would-block)。 +inline long netSendTo(int fd, const void *buf, size_t len, uint32_t dstIpBe, uint16_t dstPortHost) { + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = htons(dstPortHost); + addr.sin_addr.s_addr = dstIpBe; +#if defined(_WIN32) || defined(_WIN64) + return ::sendto((SOCKET)fd, (const char *)buf, (int)len, 0, (struct sockaddr *)&addr, sizeof(addr)); +#else + return ::sendto(fd, buf, len, 0, (struct sockaddr *)&addr, sizeof(addr)); +#endif +} + +// 无连接收(全锥形出口用):返回读到字节数;0 表示无数据;<0 表示错误(含 would-block)。 +// 同时回填对端地址(网络序 s_addr)与端口(主机序)。 +inline long netRecvFrom(int fd, void *buf, size_t len, uint32_t *outIpBe, uint16_t *outPortHost) { + struct sockaddr_in addr = {}; +#if defined(_WIN32) || defined(_WIN64) + int alen = (int)sizeof(addr); + long n = ::recvfrom((SOCKET)fd, (char *)buf, (int)len, 0, (struct sockaddr *)&addr, &alen); +#else + socklen_t alen = sizeof(addr); + long n = ::recvfrom(fd, buf, len, 0, (struct sockaddr *)&addr, &alen); +#endif + if (n >= 0) { + if (outIpBe != nullptr) { + *outIpBe = addr.sin_addr.s_addr; + } + if (outPortHost != nullptr) { + *outPortHost = ntohs(addr.sin_port); + } + } + return n; +} + +// 绑定到 (INADDR_ANY, 0):让内核立即分配一个固定出口端口,全生命周期内不变, +// 对所有目的复用同一 (出口IP,端口),实现全锥形 endpoint-independent mapping。成功返回 0。 +inline int netBindAny(int fd) { + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = 0; + addr.sin_addr.s_addr = INADDR_ANY; +#if defined(_WIN32) || defined(_WIN64) + return ::bind((SOCKET)fd, (struct sockaddr *)&addr, sizeof(addr)) == 0 ? 0 : -1; +#else + return ::bind(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0 ? 0 : -1; +#endif +} + +// 调大接收缓冲区(SO_RCVBUF)。单端口收敛时单个 fd 要收全部对端回包, +// 突发海量流可能溢出默认缓冲导致丢包(原多 socket 天然分摊,无此问题)。成功返回 0。 +inline int netSetRecvBuf(int fd, int bytes) { +#if defined(_WIN32) || defined(_WIN64) + return ::setsockopt((SOCKET)fd, SOL_SOCKET, SO_RCVBUF, (const char *)&bytes, sizeof(bytes)) == 0 ? 0 : -1; +#else + return ::setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bytes, sizeof(bytes)) == 0 ? 0 : -1; +#endif +} + +// 取本线程最近一次 socket 错误码。 +inline int netLastError() { +#if defined(_WIN32) || defined(_WIN64) + return ::WSAGetLastError(); +#else + return errno; +#endif +} + +// 错误码是否为"暂时不可用"(非阻塞下应重试,而非关闭)。 +inline bool netWouldBlock(int err) { +#if defined(_WIN32) || defined(_WIN64) + return err == WSAEWOULDBLOCK; +#else + return err == EAGAIN || err == EWOULDBLOCK; +#endif +} + +// 错误码是否为"连接进行中"(非阻塞 connect 的正常返回)。 +inline bool netInProgress(int err) { +#if defined(_WIN32) || defined(_WIN64) + return err == WSAEWOULDBLOCK || err == WSAEINPROGRESS; +#else + return err == EINPROGRESS; +#endif +} + +// 错误码转可读字符串(仅用于日志)。 +inline std::string netErrStr(int err) { +#if defined(_WIN32) || defined(_WIN64) + return "wsa error " + std::to_string(err); +#else + return std::generic_category().message(err); +#endif +} + +} // namespace candy + +#endif diff --git a/candy/src/netstack/stun.h b/candy/src/netstack/stun.h new file mode 100644 index 00000000..1a1907b1 --- /dev/null +++ b/candy/src/netstack/stun.h @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_STUN_H +#define CANDY_NETSTACK_STUN_H + +// 标准 STUN(RFC5389) 识别与分流工具(仅供 UDP 单端口收敛的 demux 使用)。 +// +// 背景(见方案 §11.2/§11.4):单端口收敛后,多个内部源共用一个出口 fd,回程 demux 依赖 +// 「该包是否带有 E 可解析且对每源唯一的标识」。STUN 的 96-bit transaction id 正是这种标识: +// - 出站 Binding Request 打洞并建 srflx 候选,本机按 txid 登记归属; +// - 回程 Binding Response 靠 txid 精确还原到源(即便多源共享同一公共 STUN 服务器); +// - 故 STUN 一律豁免数据面 FCFS(否则第二源发往共享 STUN 会被误丢,导致 gather 失败)。 +// +// 支持范围:**仅标准 STUN(RFC5389,带 magic cookie 0x2112A442)**;RFC3489 老式 STUN 无 +// magic cookie,不在支持范围(不识别,按普通数据面处理)。 + +#include +#include +#include + +namespace candy { + +// STUN 消息 class(RFC5389 §6:method 与 class 交织编码在 14-bit message type 内, +// class 由 bit8(C1)、bit4(C0) 两位构成,与具体 method 无关)。按 class 分流即可, +// 无需枚举各 method(Binding/Allocate/… 处理方式一致)。 +enum class StunClass : uint8_t { + Request = 0, // 0b00:请求(出站含 txid 需登记;入站为对端 ICE 连通性检查) + Indication = 1, // 0b01:指示(如 keepalive Binding Indication 0x0011,无需回程配对) + SuccessResp = 2, // 0b10:成功响应(回程靠 txid 还原) + ErrorResp = 3, // 0b11:错误响应(回程靠 txid 还原) +}; + +struct StunInfo { + StunClass klass; // 消息 class + uint8_t txid[12]; // 96-bit transaction id +}; + +// STUN magic cookie(RFC5389,位于 byte4-7,网络序)。 +inline constexpr uint32_t kStunMagicCookie = 0x2112A442u; + +// 识别一段载荷是否为标准 STUN 消息。命中则回填 out 并返回 true。 +// +// 三重校验(见 §11.4「STUN 识别」,避免把恰好前 4 字节等于 magic cookie 的数据包误判): +// ① byte4-7 == 0x2112A442(magic cookie) +// ② 消息类型最高 2 bit == 0(byte0 高 2 位为 0,STUN 头部固定前导) +// ③ length 字段(byte2-3) == 总长-20 且 4 字节对齐 +inline bool stunParse(const void *data, size_t len, StunInfo *out) { + // STUN 头部固定 20 字节:2(type)+2(length)+4(cookie)+12(txid)。 + if (data == nullptr || len < 20) { + return false; + } + const uint8_t *b = static_cast(data); + + // ② 消息类型最高 2 bit 必须为 0。 + if ((b[0] & 0xC0) != 0) { + return false; + } + + // ① magic cookie(byte4-7,网络序)。 + uint32_t cookie = (uint32_t(b[4]) << 24) | (uint32_t(b[5]) << 16) | (uint32_t(b[6]) << 8) | uint32_t(b[7]); + if (cookie != kStunMagicCookie) { + return false; + } + + // ③ length 字段(byte2-3,网络序) 必须等于「总长-20」且 4 字节对齐。 + uint16_t msgLen = (uint16_t(b[2]) << 8) | uint16_t(b[3]); + if ((msgLen & 0x3) != 0) { + return false; + } + if (size_t(msgLen) + 20 != len) { + return false; + } + + if (out != nullptr) { + // class 由 message type 的 bit8(C1)、bit4(C0) 两位构成。 + uint16_t type = (uint16_t(b[0]) << 8) | uint16_t(b[1]); + uint8_t c1 = (type >> 8) & 0x1; + uint8_t c0 = (type >> 4) & 0x1; + out->klass = static_cast((c1 << 1) | c0); + std::memcpy(out->txid, b + 8, 12); + } + return true; +} + +} // namespace candy + +#endif diff --git a/candy/src/netstack/udpmux.cc b/candy/src/netstack/udpmux.cc new file mode 100644 index 00000000..aa198ad8 --- /dev/null +++ b/candy/src/netstack/udpmux.cc @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: MIT +#include "netstack/udpmux.h" +#include "netstack/netstack.h" +#include "netstack/sockcompat.h" +#include "netstack/stun.h" +#include "utils/log.h" +#include +#include + +namespace candy { + +// endpointOwner 按端点 LRU 老化阈值:必须 ≤ 物理出口 NAT3 的 UDP 映射老化(运营商常 30–60s)。 +// 无 TURN 兜底,故不能比物理层活得久(否则 lwIP 以为映射还在、物理层已老化,回包被物理 NAT 丢)。 +static constexpr std::chrono::seconds kEndpointIdle{30}; +// STUN 事务表超时:覆盖 RFC5389 重传窗口(Rc*RTO≈39.5s),略给余量,防 Response 丢失致表膨胀。 +static constexpr std::chrono::seconds kStunTxnIdle{40}; +// 单出口 fd 接收缓冲:单 socket 收全部对端回包,突发海量流需大缓冲防溢出丢包。 +static constexpr int kRecvBufBytes = 4 * 1024 * 1024; +// 未命中可能由异常对端高频触发;保留 WARN 可观测性,同时限频保护日志系统。 +static constexpr std::chrono::seconds kDropWarnInterval{1}; + +UdpMux::UdpMux(NetStack *stack) + : stack(stack), fd(-1), closing(false), dropCollision(0), dropTxidMiss(0), dropEndpointMiss(0), lastLoggedDrops(0) {} + +UdpMux::~UdpMux() { + if (this->fd >= 0) { + netClose(this->fd); + this->fd = -1; + } +} + +int UdpMux::start() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + // 全局唯一落地 fd:unconnected + bindAny(固定出口端口,全生命周期不变),所有内部源复用。 + this->fd = this->stack->getOutbound().dialUdp(); + if (this->fd < 0) { + candy::logger().error("udpmux dialUdp failed"); + return -1; + } + // 收敛后单 fd 承载全部回包,显式调大接收缓冲防溢出(原多 socket 天然分摊,无此需求)。 + if (netSetRecvBuf(this->fd, kRecvBufBytes) != 0) { + candy::logger().warning(Poco::format("udpmux set SO_RCVBUF failed: %s", netErrStr(netLastError()))); + } + + candy::logger().information("udpmux started: single-port UDP convergence enabled"); + + // fd 已成功创建后才建立自持引用;失败路径不产生 shared_ptr 环,确保调用方 reset 后可析构。 + this->self = shared_from_this(); + auto holder = this->self; + int f = this->fd; + this->stack->getReactor().add(f, ReactorEvent::READ, [holder](ReactorEvent ev) { holder->onFdEvent((uint32_t)ev); }); + return 0; +#else + return -1; +#endif +} + +void UdpMux::shutdown() { + // NetStack 线程调用:清空四表(本线程独占,无锁),fd 交由 reactor 关闭。 + this->endpointOwner.clear(); + this->stunTxn.clear(); + this->ownerEndpoints.clear(); + + if (!this->closing.exchange(true)) { + auto holder = shared_from_this(); + this->stack->getReactor().post([holder] { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (holder->fd >= 0) { + holder->stack->getReactor().del(holder->fd); + netClose(holder->fd); + holder->fd = -1; + } +#endif + holder->self.reset(); + }); + } +} + +// ===================== NetStack 线程:发送侧 ===================== + +void UdpMux::sendFromSource(const std::string &innerSrc, uint32_t dstIpBe, uint16_t dstPortHost, std::string data) { + auto now = std::chrono::steady_clock::now(); + uint64_t k = endpointKey(dstIpBe, dstPortHost); + + // step 1:STUN 一律豁免数据面 FCFS(不看具体 method/class)。见 §11.4 发送 step 1。 + StunInfo info; + if (stunParse(data.data(), data.size(), &info)) { + if (info.klass == StunClass::Request) { + // 带事务的 Request:登记 txid → 内部源,供回程 Response 靠 txid 分流。 + // keepalive(Indication) 等无需回程配对的消息:豁免但不记 txid。 + std::string txid((const char *)info.txid, sizeof(info.txid)); + this->stunTxn[txid] = TxnEntry{innerSrc, now}; + } + // 对 endpointOwner:仅「存在且同源则保活」,不含则不创建(避免把共享 STUN 端点锁给某源), + // 异源则跳过、不丢(否则多源共享同一公共 STUN 会被误判碰撞丢弃)。 + auto it = this->endpointOwner.find(k); + if (it != this->endpointOwner.end() && it->second.owner == innerSrc) { + it->second.lastActive = now; + } + // step 3:直接发送(必须跳过 step 2 的 FCFS)。 + } else { + // step 2:数据包端点归属(数据面 FCFS 锁定)。 + auto it = this->endpointOwner.find(k); + if (it == this->endpointOwner.end()) { + // 首占,锁定。 + this->endpointOwner[k] = OwnerEntry{innerSrc, now}; + this->ownerEndpoints[innerSrc].insert(k); + } else if (it->second.owner != innerSrc) { + // 碰撞:D 已被别的源占用(禁区 A)——只丢后者,绝不影响首占者。 + ++this->dropCollision; + candy::logger().warning(Poco::format( + "udpmux drop(collision): dst=%08x:%hu owned by other source, dropping later source's packet", dstIpBe, + dstPortHost)); + return; + } else { + // 同源续发:保活(对端 NAT3,keepalive 维持物理映射,命脉)。 + it->second.lastActive = now; + } + } + + // step 3:post 到 Reactor 线程用全局 fd 发送(表操作已在 NetStack 线程完成)。 + auto holder = shared_from_this(); + this->stack->getReactor().post([holder, dstIpBe, dstPortHost, data = std::move(data)]() mutable { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + if (holder->closing.load() || holder->fd < 0) { + return; + } + long n = netSendTo(holder->fd, data.data(), data.size(), dstIpBe, dstPortHost); + if (n < 0 && !netWouldBlock(netLastError())) { + candy::logger().debug(Poco::format("udpmux sendto failed: %s", netErrStr(netLastError()))); + } +#endif + }); +} + +void UdpMux::onSourceGone(const std::string &innerSrc) { + // 源整体消亡(npcb 回收):经反向索引级联清掉该源名下全部 endpointOwner 条目(§11.3 兜底)。 + auto it = this->ownerEndpoints.find(innerSrc); + if (it == this->ownerEndpoints.end()) { + return; + } + for (uint64_t k : it->second) { + auto eit = this->endpointOwner.find(k); + // 仅删仍属本源的条目(老化后可能已被新首占者重锁,不能误删)。 + if (eit != this->endpointOwner.end() && eit->second.owner == innerSrc) { + this->endpointOwner.erase(eit); + } + } + this->ownerEndpoints.erase(it); +} + +void UdpMux::warnDrop(const char *kind, uint32_t peerIpBe, uint16_t peerPortHost) { + auto now = std::chrono::steady_clock::now(); + if (now - this->lastDropWarn < kDropWarnInterval) { + return; + } + this->lastDropWarn = now; + candy::logger().warning(Poco::format( + "udpmux drop(%s): peer=%08x:%hu totals(collision=%s txidMiss=%s endpointMiss=%s)", kind, peerIpBe, + peerPortHost, std::to_string(this->dropCollision), std::to_string(this->dropTxidMiss), + std::to_string(this->dropEndpointMiss))); +} + +void UdpMux::reap() { + auto now = std::chrono::steady_clock::now(); + + // endpointOwner:按端点各自 lastActive 做 LRU 老化(对齐真实 NAT 逐映射独立超时)。 + for (auto it = this->endpointOwner.begin(); it != this->endpointOwner.end();) { + if (now - it->second.lastActive > kEndpointIdle) { + // 同步从反向索引摘除该端点。 + auto oit = this->ownerEndpoints.find(it->second.owner); + if (oit != this->ownerEndpoints.end()) { + oit->second.erase(it->first); + if (oit->second.empty()) { + this->ownerEndpoints.erase(oit); + } + } + it = this->endpointOwner.erase(it); + } else { + ++it; + } + } + + // stunTxn:超时清理(防 Response 丢失导致表膨胀)。 + for (auto it = this->stunTxn.begin(); it != this->stunTxn.end();) { + if (now - it->second.createdAt > kStunTxnIdle) { + it = this->stunTxn.erase(it); + } else { + ++it; + } + } + + // 丢弃计数仅在增长时打印(可观测:判断是否频繁踩禁区)。 + uint64_t total = this->dropCollision + this->dropTxidMiss + this->dropEndpointMiss; + if (total != this->lastLoggedDrops) { + candy::logger().information(Poco::format( + "udpmux stats: endpoints=%s txns=%s drops(collision=%s txidMiss=%s endpointMiss=%s)", + std::to_string(this->endpointOwner.size()), std::to_string(this->stunTxn.size()), + std::to_string(this->dropCollision), std::to_string(this->dropTxidMiss), + std::to_string(this->dropEndpointMiss))); + this->lastLoggedDrops = total; + } +} + +// ===================== Reactor 线程:单读者 ===================== + +void UdpMux::onFdEvent(uint32_t events) { + ReactorEvent ev = (ReactorEvent)events; + if (ev & ReactorEvent::FAILURE) { + // 全局 fd 是收敛命脉,不能因偶发错误关闭;记录后继续(unconnected 下罕见)。 + candy::logger().warning("udpmux fd failure event"); + return; + } + if (ev & ReactorEvent::READ) { + onFdReadable(); + } +} + +void UdpMux::onFdReadable() { +#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32) || defined(_WIN64) + char buf[65536]; + while (true) { + // 单 socket 收任意对端回包,取回真实对端地址/端口,连同数据投递到 NetStack 线程做 demux。 + uint32_t peerIpBe; + uint16_t peerPortHost; + long n = netRecvFrom(this->fd, buf, sizeof(buf), &peerIpBe, &peerPortHost); + if (n > 0) { + std::string data(buf, n); + auto holder = shared_from_this(); + this->stack->postToStack([holder, peerIpBe, peerPortHost, data = std::move(data)]() mutable { + holder->demux(peerIpBe, peerPortHost, std::move(data)); + }); + continue; + } + if (n == 0) { + return; + } + if (netWouldBlock(netLastError())) { + return; + } + // 罕见错误:记录但保留 fd(不像 per-session 那样关闭)。 + candy::logger().debug(Poco::format("udpmux recvfrom failed: %s", netErrStr(netLastError()))); + return; + } +#endif +} + +// ===================== NetStack 线程:接收侧 demux ===================== + +void UdpMux::demux(uint32_t rIpBe, uint16_t rPortHost, std::string data) { + auto now = std::chrono::steady_clock::now(); + uint64_t k = endpointKey(rIpBe, rPortHost); + + // step 1:STUN 按类型分流。 + StunInfo info; + if (stunParse(data.data(), data.size(), &info)) { + if (info.klass == StunClass::SuccessResp || info.klass == StunClass::ErrorResp) { + // 1a:Response/Error 走 txid 反查。 + std::string txid((const char *)info.txid, sizeof(info.txid)); + auto it = this->stunTxn.find(txid); + if (it != this->stunTxn.end()) { + std::string innerSrc = it->second.innerSrc; + this->stunTxn.erase(it); + this->stack->injectUdpReply(innerSrc, rIpBe, rPortHost, std::move(data)); + return; + } + ++this->dropTxidMiss; // txid 对不上:非本机发起或已超时。 + this->warnDrop("stun-txid-miss", rIpBe, rPortHost); + return; + } + // 1b:入站 Request/Indication(对端 ICE 连通性检查)——txid 由对端生成,不在 stunTxn, + // 必须走 endpointOwner(前提:我方已先向 R 发过包并 FCFS 锁定)。 + auto it = this->endpointOwner.find(k); + if (it != this->endpointOwner.end()) { + it->second.lastActive = now; + this->stack->injectUdpReply(it->second.owner, rIpBe, rPortHost, std::move(data)); + return; + } + ++this->dropEndpointMiss; // 我方未先发起,无归属。 + this->warnDrop("stun-endpoint-miss", rIpBe, rPortHost); + return; + } + + // step 2:数据包走 endpointOwner。 + auto it = this->endpointOwner.find(k); + if (it != this->endpointOwner.end()) { + it->second.lastActive = now; // 接收侧保活:对端单向来包也算活跃,防误回收。 + this->stack->injectUdpReply(it->second.owner, rIpBe, rPortHost, std::move(data)); + return; + } + // 未命中:对端冷启动主动连 / 已老化 / 禁区碰撞受害包(对端为 NAT3 时 R==D 不会误丢)。 + ++this->dropEndpointMiss; + this->warnDrop("data-endpoint-miss", rIpBe, rPortHost); +} + +} // namespace candy diff --git a/candy/src/netstack/udpmux.h b/candy/src/netstack/udpmux.h new file mode 100644 index 00000000..706ed5a2 --- /dev/null +++ b/candy/src/netstack/udpmux.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +#ifndef CANDY_NETSTACK_UDPMUX_H +#define CANDY_NETSTACK_UDPMUX_H + +#include +#include +#include +#include +#include +#include +#include + +namespace candy { + +class NetStack; + +// UdpMux:UDP 单端口收敛的全局多路复用器(见方案 §11)。 +// +// 与「每源全锥形」的区别:每源模式下每个内部源(origSrc,origSrcPort)各持一个 unconnected 落地 +// fd、各自 recvfrom/sendto;UdpMux 模式下**所有内部源共用一个** unconnected fd、一个出口端口, +// 回程靠下面四张表做 demux 还原到各自内部源。收敛的是「落地 fd」,不收敛「npcb」——lwIP 仍 +// 一源一 npcb(保留在 NetStack::udpSessions 作 innerNpcb 定位表),回程注入仍逐源进行。 +// +// 性质(前提:双端 NAT3 + 无 TURN):单端口后过滤维度为「地址且端口依赖过滤(APDF)=端口受限锥」, +// 恰与物理出口 NAT3 对齐,对端为 NAT3(cone) 时回包端点 R==D 必命中、无损。 +// +// 线程契约(§11.3a,必须遵守):四张表**全部只在 NetStack 线程读写、无锁**。 +// - 发送侧判定在 NetStack 线程(SessionUdp::onPcbRecv → sendFromSource),通过后才 post 到 +// Reactor 线程做 netSendTo; +// - 接收侧 Reactor 线程只 recvfrom 收字节+对端地址,随即 postToStack 到 NetStack 线程做 demux。 +// Reactor 线程绝不触碰四表。 +class UdpMux : public std::enable_shared_from_this { +public: + explicit UdpMux(NetStack *stack); + ~UdpMux(); + + // NetStack 线程:建立全局落地 fd(unconnected + bindAny + 调大 SO_RCVBUF),注册单读者到 + // Reactor。成功返回 0,失败返回 -1。 + int start(); + + // NetStack 线程:栈拆除(重连)时关闭全局 fd、清空四表。 + void shutdown(); + + // NetStack 线程(SessionUdp::onPcbRecv 内):某内部源 innerSrc 发往远端 D 的一份报文。 + // 内部做 STUN 识别/txid 登记/数据面 FCFS 锁,判定通过后 post 到 Reactor 用全局 fd 发送。 + void sendFromSource(const std::string &innerSrc, uint32_t dstIpBe, uint16_t dstPortHost, std::string data); + + // NetStack 线程:某内部源整体消亡(npcb 回收)时,经反向索引级联清掉该源名下全部 + // endpointOwner 条目,防端点泄漏 + 新源被 FCFS 误挡(§11.3 兜底级联)。 + void onSourceGone(const std::string &innerSrc); + + // NetStack 线程:按端点各自 lastActive 做 LRU 老化 endpointOwner;清理超时 stunTxn。 + // loop 节流调用。 + void reap(); + +private: + // ---- Reactor 线程 ---- + void onFdEvent(uint32_t events); + void onFdReadable(); + + // ---- NetStack 线程 ---- + // 收到远端 R=(rIpBe,rPortHost) 的一份回包,按 STUN txid / endpointOwner 反查还原内部源并注入。 + void demux(uint32_t rIpBe, uint16_t rPortHost, std::string data); + // 丢弃告警限频:保留端点和分类信息,同时避免异常流量刷爆日志。 + void warnDrop(const char *kind, uint32_t peerIpBe, uint16_t peerPortHost); + + // 远端端点 key:(R_ip 网络序) 高 32 位 | (R_port 主机序) 低 16 位。 + static uint64_t endpointKey(uint32_t ipBe, uint16_t portHost) { + return (uint64_t(ipBe) << 16) | uint64_t(portHost); + } + +private: + NetStack *stack; + int fd; + std::atomic closing; + std::shared_ptr self; // 自持,保证跨线程投递期间存活(对齐 SessionUdp) + + // ---- 四张表(全部仅 NetStack 线程访问,无锁)---- + + // 反向 demux 主表:远端数据端点 -> {首占内部源 owner, 该端点自身活跃时间}。FCFS 首占锁定。 + struct OwnerEntry { + std::string owner; // InnerSrc(= SessionUdp::key,源二元组字节串) + std::chrono::steady_clock::time_point lastActive; + }; + std::unordered_map endpointOwner; + + // STUN 事务表:txid(12B) -> {内部源, 登记时间}。Request 登记 / Response 消费 / 超时清理。 + struct TxnEntry { + std::string innerSrc; + std::chrono::steady_clock::time_point createdAt; + }; + std::unordered_map stunTxn; + + // 反向索引:内部源 -> 它占用的全部远端端点集合(供源整体消亡时兜底级联清 endpointOwner)。 + std::unordered_map> ownerEndpoints; + + // 注:innerNpcb(内部源 -> npcb)复用 NetStack::udpSessions,回程注入经 + // NetStack::injectUdpReply 定位,故此处不再单独维护。 + + // ---- 丢弃计数(可观测性,§11.7 点5)---- + uint64_t dropCollision; // 数据面 FCFS 碰撞(≥2 源发往同一数据端点) + uint64_t dropTxidMiss; // STUN Response txid 未命中 + uint64_t dropEndpointMiss; // 数据 / 入站 Request 的 endpoint 未命中 + uint64_t lastLoggedDrops; // 上次日志时的丢弃总数(仅在增长时打印) + std::chrono::steady_clock::time_point lastDropWarn; +}; + +} // namespace candy + +#endif diff --git a/candy/src/peer/manager.cc b/candy/src/peer/manager.cc index 1841c221..255b14c6 100644 --- a/candy/src/peer/manager.cc +++ b/candy/src/peer/manager.cc @@ -57,6 +57,8 @@ int PeerManager::setLocalhost(const std::string &ip) { int PeerManager::run(Client *client) { this->client = client; this->localP2PDisabled = false; + this->p2pOnlyDropCount = 0; + this->lastP2POnlyDropWarn = {}; if (this->stun.update()) { candy::logger().fatal("update stun failed"); @@ -183,6 +185,23 @@ int PeerManager::sendPacketRelay(IP4 dst, const Msg &msg) { return sendPacketDirect(dst, msg); } +IP4 PeerManager::resolvePeerTarget(IP4 dst) { + std::shared_lock rtTableLock(this->rtTableMutex); + auto it = this->rtTableMap.find(dst); + return it == this->rtTableMap.end() ? dst : it->second.next; +} + +void PeerManager::warnP2POnlyDrop(IP4 peer) { + ++this->p2pOnlyDropCount; + auto now = std::chrono::steady_clock::now(); + if (now - this->lastP2POnlyDropWarn < std::chrono::seconds(5)) { + return; + } + this->lastP2POnlyDropWarn = now; + candy::logger().warning(Poco::format("p2p-only drop: peer=%s p2p link unavailable total=%s", peer.toString(), + std::to_string(this->p2pOnlyDropCount))); +} + int PeerManager::sendPubInfo(CoreMsg::PubInfo info) { info.src = getClient().address(); if (info.local) { @@ -205,6 +224,12 @@ int PeerManager::handlePacket(Msg msg) { if (!sendPacket(header->daddr, msg)) { return 0; } + if (getClient().getP2POnly()) { + IP4 peer = resolvePeerTarget(header->daddr); + handleTryP2P(Msg(MsgKind::TRYP2P, peer.toString())); + warnP2POnlyDrop(peer); + return 0; + } getClient().getWsMsgQueue().write(std::move(msg)); return 0; } @@ -232,7 +257,7 @@ int PeerManager::handleTryP2P(Msg msg) { std::shared_lock lock(this->ipPeerMutex); auto it = this->ipPeerMap.find(src); if (it != this->ipPeerMap.end()) { - it->second.tryConnecct(); + it->second.tryConnecct(getClient().getP2POnly()); return 0; } } diff --git a/candy/src/peer/manager.h b/candy/src/peer/manager.h index ecc7ad51..986893b7 100644 --- a/candy/src/peer/manager.h +++ b/candy/src/peer/manager.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -95,6 +96,8 @@ class PeerManager { int sendPacket(IP4 dst, const Msg &msg); int sendPacketDirect(IP4 dst, const Msg &msg); int sendPacketRelay(IP4 dst, const Msg &msg); + IP4 resolvePeerTarget(IP4 dst); + void warnP2POnlyDrop(IP4 peer); Address tunAddr; @@ -146,6 +149,8 @@ class PeerManager { int discoveryInterval = 0; int routeCost = 0; + uint64_t p2pOnlyDropCount = 0; + std::chrono::steady_clock::time_point lastP2POnlyDropWarn; Client &getClient(); Client *client; diff --git a/candy/src/peer/peer.cc b/candy/src/peer/peer.cc index b52f9491..5d7cec27 100644 --- a/candy/src/peer/peer.cc +++ b/candy/src/peer/peer.cc @@ -48,8 +48,8 @@ Peer::Peer(const IP4 &addr, PeerManager *peerManager) : peerManager(peerManager) Peer::~Peer() {} -void Peer::tryConnecct() { - if (this->state == PeerState::INIT) { +void Peer::tryConnecct(bool retryFailed) { + if (this->state == PeerState::INIT || (retryFailed && this->state == PeerState::FAILED)) { updateState(PeerState::PREPARING); } } diff --git a/candy/src/peer/peer.h b/candy/src/peer/peer.h index 81431d35..2f92584e 100644 --- a/candy/src/peer/peer.h +++ b/candy/src/peer/peer.h @@ -40,7 +40,7 @@ class Peer { ~Peer(); void tick(); - void tryConnecct(); + void tryConnecct(bool retryFailed = false); void handleStunResponse(); void handlePubInfo(IP4 ip, uint16_t port, bool local = false); diff --git a/candy/src/tun/linux.cc b/candy/src/tun/linux.cc index f41857f4..3a28a77d 100644 --- a/candy/src/tun/linux.cc +++ b/candy/src/tun/linux.cc @@ -44,6 +44,10 @@ class LinuxTun { return 0; } + int getMTU() { + return this->mtu; + } + // 配置网卡,设置路由 int up() { this->tunFd = open("/dev/net/tun", O_RDWR); @@ -262,6 +266,12 @@ int Tun::setMTU(int mtu) { return 0; } +int Tun::getMTU() const { + std::shared_ptr tun; + tun = std::any_cast>(this->impl); + return tun->getMTU(); +} + int Tun::up() { std::shared_ptr tun; tun = std::any_cast>(this->impl); diff --git a/candy/src/tun/macos.cc b/candy/src/tun/macos.cc index 895c1189..a0a30ed1 100644 --- a/candy/src/tun/macos.cc +++ b/candy/src/tun/macos.cc @@ -53,6 +53,10 @@ class MacTun { return 0; } + int getMTU() { + return this->mtu; + } + int up() { // 创建设备,操作系统不允许自定义设备名,只能由内核分配 this->tunFd = socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL); @@ -318,6 +322,12 @@ int Tun::setMTU(int mtu) { return 0; } +int Tun::getMTU() const { + std::shared_ptr tun; + tun = std::any_cast>(this->impl); + return tun->getMTU(); +} + int Tun::up() { std::shared_ptr tun; tun = std::any_cast>(this->impl); diff --git a/candy/src/tun/tun.cc b/candy/src/tun/tun.cc index 767ae459..c6431d9e 100644 --- a/candy/src/tun/tun.cc +++ b/candy/src/tun/tun.cc @@ -69,6 +69,14 @@ int Tun::handleTunDevice() { return IP4(); }(); if (!nextHop.empty()) { +#ifdef CANDY_NETSTACK + // 发起端仅在 userspace 模式记录正向流:内核模式做 SNAT 且不喂 lwIP,无需跟踪, + // 门控可省纯 kernel 网关的内存/CPU。落地端据此表用反向五元组区分新入站/返程。 + // 必须在 insert 封 IPIP 前记录:此时 buffer 仍是原始内层单层包。 + if (getClient().getUserspaceStack()) { + recordInitiatedFlow(buffer); + } +#endif buffer.insert(0, sizeof(IP4Header), 0); header = (IP4Header *)buffer.data(); header->protocol = 0x04; @@ -114,14 +122,156 @@ int Tun::handlePacket(Msg msg) { return 0; } IP4Header *header = (IP4Header *)msg.data.data(); + +#ifdef CANDY_NETSTACK + // userspace 模式下的新入站连接:内层源不可路由的 IPIP 包整包交给 NetStack 用 lwIP + // 终结。这一分支不剥壳、不写内核 tun,直接经 MsgQueue 转交 netstack 模块(跨模块 + // 数据一律走 MsgQueue,不直接持有 netstack)。判定完成后提前返回,下方只保留唯一 + // 的一处 erase + write。未编译 CANDY_NETSTACK 时本分支不存在,一律走内核转发。 + if (header->isIPIP() && !isReturnByFlow(*header, msg.data.size()) && getClient().getUserspaceStack()) { + this->client->getNetStackMsgQueue().write(Msg(MsgKind::NETSTACK, std::move(msg.data))); + return 0; + } +#endif + + // 其余情况都要写内核 tun: + // - IPIP 返程/中转,或 kernel 模式的 IPIP 入站:剥掉 IPIP 外层(20B)后写。 + // - 非 IPIP 的普通包:原样写。 if (header->isIPIP()) { msg.data.erase(0, sizeof(IP4Header)); - header = (IP4Header *)msg.data.data(); } write(msg.data); return 0; } +bool Tun::isReturnTraffic(const IP4Header &header, size_t size) { + // 内层源地址落在本机已知路由子网内 => 本机(经本网关)发起流量的返程/中转。 + // 复用本就为路由维护的 sysRtTable:「本机能发起到的子网」== 「sysRtTable 命中的 + // 子网」,O(1) 空间,无需额外发起流跟踪表。 + if (size < sizeof(IP4Header) * 2) { + return false; + } + const IP4Header *inner = &header + 1; + std::shared_lock lock(this->sysRtMutex); + for (auto const &rt : sysRtTable) { + if ((inner->saddr & rt.mask) == rt.dst) { + return true; + } + } + return false; +} + +bool Tun::parseInnerL4(const uint8_t *ipPkt, std::size_t len, FlowKey &out) { + // 从一个裸 IPv4 包解析 TCP/UDP 五元组。所有偏移访问前都做长度校验防越界。 + if (len < sizeof(IP4Header)) { + return false; + } + const IP4Header *ip = (const IP4Header *)ipPkt; + if ((ip->version_ihl >> 4) != 4) { + return false; + } + // 仅跟踪 TCP(6)/UDP(17):其余(如 ICMP)交回退启发式处理。 + if (ip->protocol != 0x06 && ip->protocol != 0x11) { + return false; + } + // 分片:非首片(offset!=0)没有 L4 头,按 IHL 偏移读到的是 payload,会构造出垃圾键, + // 导致同一数据报首片/余片判定不一致、重组崩坏。一律跳过(返回 false 回退)。 + uint16_t fragOff = ntoh(ip->frag_off); + if ((fragOff & 0x1FFF) != 0) { + return false; + } + std::size_t ihl = (ip->version_ihl & 0x0F) * 4; + if (ihl < sizeof(IP4Header)) { + return false; + } + // 源/目的端口都在 L4 头前 4 字节,需保证 IP 头 + 4 字节可读。 + if (len < ihl + 4) { + return false; + } + const uint8_t *l4 = ipPkt + ihl; + uint16_t srcPort, dstPort; + std::memcpy(&srcPort, l4, sizeof(uint16_t)); + std::memcpy(&dstPort, l4 + 2, sizeof(uint16_t)); + // 端口按网络字节序原样存(记录与查询表示一致即可,无需 ntoh)。 + out.proto = ip->protocol; + out.srcIP = ip->saddr; + out.srcPort = srcPort; + out.dstIP = ip->daddr; + out.dstPort = dstPort; + return true; +} + +void Tun::recordInitiatedFlow(const std::string &origPacket) { + // 发起端:本机 LAN 主动发起、经本网关封 IPIP 转发的正向流。按内层五元组记录, + // 返程回来时落地端用反向五元组匹配到本条 => 判返程走内核。 + FlowKey key; + if (!parseInnerL4((const uint8_t *)origPacket.data(), origPacket.size(), key)) { + return; + } + auto now = std::chrono::steady_clock::now(); + std::unique_lock lock(this->flowMutex); + this->initiatedFlows[key] = now; + reapIdleFlows(); +} + +bool Tun::isReturnByFlow(const IP4Header &header, size_t size) { + // 落地端:区分「别人主动发起的新入站」(喂 lwIP) 与「本机发起流的返程」(走内核)。 + if (size < sizeof(IP4Header) * 2) { + return false; + } + const uint8_t *inner = (const uint8_t *)(&header + 1); + std::size_t innerLen = size - sizeof(IP4Header); + FlowKey fwd; + if (!parseInnerL4(inner, innerLen, fwd)) { + // 非 TCP/UDP(如 ICMP)或分片:无法做五元组精确匹配,回退旧的子网启发式, + // 保持 ICMP 等现状零回归。 + return isReturnTraffic(header, size); + } + // 用入站包的反向五元组还原发起方向:入站是 peer->self,发起过则表里有 self->peer。 + FlowKey rev; + rev.proto = fwd.proto; + rev.srcIP = fwd.dstIP; + rev.srcPort = fwd.dstPort; + rev.dstIP = fwd.srcIP; + rev.dstPort = fwd.srcPort; + std::unique_lock lock(this->flowMutex); + auto it = this->initiatedFlows.find(rev); + if (it != this->initiatedFlows.end()) { + // 命中:本机之前发起过该流,此入站包是其返程;刷新时间戳(长连接保活)。 + it->second = std::chrono::steady_clock::now(); + return true; + } + return false; +} + +void Tun::reapIdleFlows() { + // 调用方已持有 flowMutex。节流:每 ~10s 才真正扫描一次,避免每包遍历。 + auto now = std::chrono::steady_clock::now(); + bool overCapacity = this->initiatedFlows.size() > 4096; + if (!overCapacity && now - this->lastFlowReap < std::chrono::seconds(10)) { + return; + } + this->lastFlowReap = now; + // 空闲 > 300s 的流剔除:返程通常秒级到达,长连接的正向包会持续刷新时间戳。 + for (auto it = this->initiatedFlows.begin(); it != this->initiatedFlows.end();) { + if (now - it->second > std::chrono::seconds(300)) { + it = this->initiatedFlows.erase(it); + } else { + ++it; + } + } + // 容量兜底:清理过期项后仍超限(如 LAN 设备扫描/伪造多目的),淘汰最旧项防内存爆炸。 + while (this->initiatedFlows.size() > 4096) { + auto oldest = this->initiatedFlows.begin(); + for (auto it = this->initiatedFlows.begin(); it != this->initiatedFlows.end(); ++it) { + if (it->second < oldest->second) { + oldest = it; + } + } + this->initiatedFlows.erase(oldest); + } +} + int Tun::handleTunAddr(Msg msg) { if (setAddress(msg.data)) { return -1; diff --git a/candy/src/tun/tun.h b/candy/src/tun/tun.h index 81738c16..8fca6faf 100644 --- a/candy/src/tun/tun.h +++ b/candy/src/tun/tun.h @@ -5,15 +5,43 @@ #include "core/message.h" #include "core/net.h" #include +#include +#include #include +#include #include #include #include +#include namespace candy { class Client; +// 发起流五元组:唯一标识一条「本机 LAN 主动发起、经本网关封 IPIP 转发」的流。 +// 各字段按网络字节序原样存储(记录与查询表示一致即可,无需 ntoh 转换)。 +struct FlowKey { + uint8_t proto; // 6=TCP, 17=UDP + uint32_t srcIP; + uint16_t srcPort; + uint32_t dstIP; + uint16_t dstPort; + bool operator==(const FlowKey &o) const { + return proto == o.proto && srcIP == o.srcIP && srcPort == o.srcPort && dstIP == o.dstIP && dstPort == o.dstPort; + } +}; + +// net.h 只提供了 hash,FlowKey 需自定义 hash。 +struct FlowKeyHash { + std::size_t operator()(const FlowKey &k) const { + std::size_t h = std::hash{}(k.srcIP); + h ^= std::hash{}(k.dstIP) + 0x9e3779b9 + (h << 6) + (h >> 2); + h ^= std::hash{}((uint32_t(k.srcPort) << 16) | k.dstPort) + 0x9e3779b9 + (h << 6) + (h >> 2); + h ^= std::hash{}(k.proto) + 0x9e3779b9 + (h << 6) + (h >> 2); + return h; + } +}; + class Tun { public: Tun(); @@ -21,6 +49,7 @@ class Tun { int setName(const std::string &name); int setMTU(int mtu); + int getMTU() const; int run(Client *client); int wait(); @@ -36,6 +65,17 @@ class Tun { // 处理来自消息队列的数据 int handleTunQueue(); int handlePacket(Msg msg); + // IPIP 内层源是否落在本机路由子网内(返程/中转流量判定)。非 TCP/UDP(如 ICMP) + // 场景下作为 isReturnByFlow 的回退启发式使用。 + bool isReturnTraffic(const IP4Header &header, std::size_t size); + // 基于发起流跟踪表的返程判定:TCP/UDP 用反向五元组精确匹配,其余回退 isReturnTraffic。 + bool isReturnByFlow(const IP4Header &header, std::size_t size); + // 从一个 IPv4 包解析 TCP/UDP 五元组(含分片/越界校验);成功填 out 返回 true。 + bool parseInnerL4(const uint8_t *ipPkt, std::size_t len, FlowKey &out); + // 发起端封 IPIP 前记录本机 LAN 主动发起的正向流(仅 TCP/UDP)。 + void recordInitiatedFlow(const std::string &origPacket); + // 老化清理:剔除空闲超时的发起流,并对表容量兜底。 + void reapIdleFlows(); int handleTunAddr(Msg msg); int handleSysRt(Msg msg); @@ -56,6 +96,14 @@ class Tun { std::shared_mutex sysRtMutex; std::list sysRtTable; + // 发起流跟踪表:记录本机 LAN 主动发起、经本网关封 IPIP 转发的正向流。 + // 落地端据此用反向五元组区分「别人发起的新入站」(喂 lwIP) 与「本机发起流的返程」 + // (走内核)。tunThread 写、msgThread 读,用独立 mutex 保护(临界区仅 hash 操作, + // 不得与 sysRtMutex 嵌套持有以防死锁)。 + std::mutex flowMutex; + std::unordered_map initiatedFlows; + std::chrono::steady_clock::time_point lastFlowReap; + private: std::any impl; diff --git a/candy/src/tun/unknown.cc b/candy/src/tun/unknown.cc index c04ea6a7..4b4cc678 100644 --- a/candy/src/tun/unknown.cc +++ b/candy/src/tun/unknown.cc @@ -23,6 +23,10 @@ int Tun::setMTU(int mtu) { return -1; } +int Tun::getMTU() { + return -1; +} + int Tun::up() { return -1; } diff --git a/candy/src/tun/windows.cc b/candy/src/tun/windows.cc index 703659bf..1d2d71d0 100644 --- a/candy/src/tun/windows.cc +++ b/candy/src/tun/windows.cc @@ -115,6 +115,10 @@ class WindowsTun { return 0; } + int getMTU() { + return this->mtu; + } + int up() { if (!Holder::Ok()) { candy::logger().fatal("init wintun failed"); @@ -315,6 +319,12 @@ int Tun::setMTU(int mtu) { return 0; } +int Tun::getMTU() const { + std::shared_ptr tun; + tun = std::any_cast>(this->impl); + return tun->getMTU(); +} + int Tun::up() { std::shared_ptr tun; tun = std::any_cast>(this->impl); diff --git a/candy/src/websocket/client.cc b/candy/src/websocket/client.cc index 4bd1bea8..7c058c80 100644 --- a/candy/src/websocket/client.cc +++ b/candy/src/websocket/client.cc @@ -248,6 +248,10 @@ void WebSocketClient::handleForwardMsg(std::string buffer) { IP4Header *header = (IP4Header *)buffer.data(); // 每次通过服务端转发收到报文都触发一次尝试 P2P 连接, 用于暗示通过服务端转发是个非常耗时的操作 this->client->getPeerMsgQueue().write(Msg(MsgKind::TRYP2P, header->saddr.toString())); + if (this->client->getP2POnly()) { + candy::logger().debug(Poco::format("p2p-only drop websocket forward: peer=%s", header->saddr.toString())); + return; + } // 最后把报文移动到 TUN 模块, 因为有移动操作所以必须在最后执行 this->client->getTunMsgQueue().write(Msg(MsgKind::PACKET, std::move(buffer))); } diff --git a/candy/src/websocket/server.cc b/candy/src/websocket/server.cc index 6bfb7325..c6d131ef 100644 --- a/candy/src/websocket/server.cc +++ b/candy/src/websocket/server.cc @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include diff --git a/cmake/lwip.cmake b/cmake/lwip.cmake new file mode 100644 index 00000000..b93476a6 --- /dev/null +++ b/cmake/lwip.cmake @@ -0,0 +1,70 @@ +include(FetchContent) + +set(LWIP_GIT_REPOSITORY "https://github.com/Bepartofyou/lwip.git") +set(LWIP_GIT_TAG "9b6fe14f0a44c477e10bf0e68da56b3498a91352") + +FetchContent_Declare( + lwip + GIT_REPOSITORY ${LWIP_GIT_REPOSITORY} + GIT_TAG ${LWIP_GIT_TAG} +) +FetchContent_GetProperties(lwip) +if(NOT lwip_POPULATED) + FetchContent_Populate(lwip) +endif() + +set(LWIP_SRC_DIR ${lwip_SOURCE_DIR}/src) +# 直接复用 fork(Bepartofyou/lwip)自带的移植层与配置: +# - src/ports/unix|win32/lib/sys_arch.c:平台适配,不再手写。 +# - src/ports/include/arch/*.h:转发头,按平台自动转发到 unix/win32 实现头。 +# - src/ports/include/lwipopts.h:已在 fork 内改成 candy 定制配置, +# candy 不再单独维护 lwipopts.h。 +# 另:udp.c 的 IPv4-only 兼容修复(IP_GET_TYPE)也已合入 fork,无需再打补丁。 +set(LWIP_PORT_INCLUDE_DIR ${LWIP_SRC_DIR}/ports/include) +if(WIN32) + set(LWIP_PORT_DIR ${LWIP_SRC_DIR}/ports/win32) +else() + set(LWIP_PORT_DIR ${LWIP_SRC_DIR}/ports/unix) +endif() + +set(LWIP_CORE_SOURCES + ${LWIP_SRC_DIR}/core/init.c + ${LWIP_SRC_DIR}/core/def.c + ${LWIP_SRC_DIR}/core/inet_chksum.c + ${LWIP_SRC_DIR}/core/ip.c + ${LWIP_SRC_DIR}/core/mem.c + ${LWIP_SRC_DIR}/core/memp.c + ${LWIP_SRC_DIR}/core/netif.c + ${LWIP_SRC_DIR}/core/pbuf.c + ${LWIP_SRC_DIR}/core/raw.c + ${LWIP_SRC_DIR}/core/stats.c + ${LWIP_SRC_DIR}/core/sys.c + ${LWIP_SRC_DIR}/core/tcp.c + ${LWIP_SRC_DIR}/core/tcp_in.c + ${LWIP_SRC_DIR}/core/tcp_out.c + ${LWIP_SRC_DIR}/core/udp.c + ${LWIP_SRC_DIR}/core/timeouts.c +) + +set(LWIP_CORE_IPV4_SOURCES + ${LWIP_SRC_DIR}/core/ipv4/icmp.c + ${LWIP_SRC_DIR}/core/ipv4/ip4.c + ${LWIP_SRC_DIR}/core/ipv4/ip4_addr.c + ${LWIP_SRC_DIR}/core/ipv4/ip4_frag.c +) + +set(LWIP_PORT_SOURCES + ${LWIP_PORT_DIR}/lib/sys_arch.c +) + +add_library(lwip STATIC + ${LWIP_CORE_SOURCES} + ${LWIP_CORE_IPV4_SOURCES} + ${LWIP_PORT_SOURCES} +) + +target_include_directories(lwip PUBLIC + ${LWIP_PORT_INCLUDE_DIR} + ${LWIP_PORT_DIR}/include + ${LWIP_SRC_DIR}/include +) diff --git a/dockerfile b/dockerfile index b70f7d75..f886b40a 100644 --- a/dockerfile +++ b/dockerfile @@ -4,8 +4,11 @@ RUN apk add openssl poco FROM base AS build RUN apk add git cmake ninja pkgconf g++ openssl-dev poco-dev linux-headers +# 用户态协议栈编译开关,默认 OFF:官方发行镜像零影响。 +# 回归/需要 userspace 落地时用 --build-arg CANDY_NETSTACK=ON 构建。 +ARG CANDY_NETSTACK=OFF COPY . candy -RUN cd candy && cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build && cmake --install build +RUN cd candy && cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCANDY_NETSTACK=${CANDY_NETSTACK} && cmake --build build && cmake --install build FROM base AS product RUN install -D /dev/null /var/lib/candy/lost