TCP/UDP connectivity over Tailscale now works via lwIP integration. Standard BSD sockets are fully functional on the Tailscale interface.
Verified working:
- TCP connections (echo server on port 6666/7777)
- ICMP ping responses
- Full TCP handshake (SYN → SYN-ACK → ACK → data → FIN)
Create a virtual lwIP network interface (TUN-like) for Tailscale, allowing standard BSD sockets to work transparently over the Tailscale network. ESPHome's web server and other services will be accessible on both WiFi and Tailscale IPs.
- Dual-stack: Services accessible on both WiFi (192.168.x.x) and Tailscale (100.x.x.x) IPs
- Replace TailscaleSocket API: Remove custom socket API, use only standard BSD sockets
- Memory budget: ~20-30KB additional
UDP/DERP → WireGuard decrypt → Manual IP/TCP parsing → TailscaleSocket callbacks
UDP/DERP → WireGuard decrypt → TailscaleNetif → lwIP TCP/UDP stack → Standard sockets
New files:
components/tailscale/tailscale_netif.hcomponents/tailscale/tailscale_netif.cpp
Class design:
class TailscaleNetif {
public:
bool init(const std::string& tailscale_ip); // Create esp_netif with IP
void start(); // Bring interface up
void stop(); // Bring interface down
void receive(const uint8_t* ip_packet, size_t len); // Inject into lwIP
using TransmitCallback = std::function<void(const uint8_t*, size_t)>;
void set_transmit_callback(TransmitCallback cb); // Called when lwIP sends
private:
esp_netif_t* netif_;
esp_netif_driver_base_t driver_base_;
TransmitCallback transmit_cb_;
bool running_;
};Key implementation details:
- Custom
esp_netif_netstack_configwith lwIP init and input functions - Interface type: Point-to-point (no ARP), similar to PPP/SLIP
- MTU: 1280 bytes (safe for WireGuard overhead)
- Route priority: 50 (lower than WiFi's 100, so WiFi is default for non-Tailscale IPs)
- Subnet: 100.0.0.0/8 for Tailscale address space
Modify tailscale.h:
// Add member
std::unique_ptr<TailscaleNetif> tailscale_netif_;Modify tailscale.cpp:
- Initialization (in
handle_fetching_map_state_()after receiving IP):
tailscale_netif_ = make_unique<TailscaleNetif>();
tailscale_netif_->init(node_config_.ipv4_address);
tailscale_netif_->set_transmit_callback([this](const uint8_t* pkt, size_t len) {
route_outgoing_packet_(pkt, len); // Send via WireGuard
});
tailscale_netif_->start();- Receive path (modify WireGuard decrypt callback):
wg_device_manager_->set_decrypt_callback(
[this](const std::string& peer_ip, const uint8_t* ip_packet, size_t len) {
if (tailscale_netif_) {
tailscale_netif_->receive(ip_packet, len); // Inject into lwIP
}
});- Transmit path (new method
route_outgoing_packet_()):
void route_outgoing_packet_(const uint8_t* ip_packet, size_t len) {
// Extract destination IP from packet header
uint32_t dst_ip = extract_dst_ip(ip_packet);
std::string dst_str = ip_to_string(dst_ip);
// Send via WireGuard to the peer
wg_device_manager_->send_ip_packet(dst_str, ip_packet, len);
}Files to delete:
components/tailscale/tailscale_socket.hcomponents/tailscale/tailscale_socket.cppcomponents/tailscale/echo_socket.h
Code to remove from tailscale.h:
enum class TcpStatestruct TcpConnectionclass TailscaleSocketforward declaration- TCP-related methods:
bind_socket(),send_tcp_data(),close_tcp_connection(),handle_tcp_packet_(),send_tcp_packet_(), checksum functions - TCP-related members:
tcp_connections_,bound_sockets_
Code to remove from tailscale.cpp:
handle_tcp_packet_()implementation (~200 lines)send_tcp_packet_()implementation (~120 lines)- TCP socket API implementations
- Manual ICMP echo handling in decrypt callback
- Echo server socket setup
Modify http_server.h/cpp:
- Replace
TailscaleServerSocketbase class with standard socket implementation - Use
socket(),bind(),listen(),accept()APIs - Bind to
INADDR_ANY(0.0.0.0) to listen on all interfaces
void HTTPServer::start(uint16_t port) {
int sock = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = INADDR_ANY };
bind(sock, (sockaddr*)&addr, sizeof(addr));
listen(sock, 4);
// Accept loop in FreeRTOS task
}| File | Changes |
|---|---|
tailscale.h |
Add TailscaleNetif member, remove TCP stack declarations |
tailscale.cpp |
Init netif, modify decrypt callback, remove TCP handling |
tailscale_netif.h |
NEW - TailscaleNetif class declaration |
tailscale_netif.cpp |
NEW - esp_netif driver implementation |
tailscale_socket.h |
DELETE |
tailscale_socket.cpp |
DELETE |
echo_socket.h |
DELETE |
http_server.h/cpp |
Rewrite to use BSD sockets |
.esphome/idf_components/.../esp_modem/src/esp_modem_netif.cpp- esp_netif driver pattern.esphome/idf_components/.../slip_modem/library/slip_modem_netif.c- Custom netstack pattern
- Use
LOCK_TCPIP_CORE()/UNLOCK_TCPIP_CORE()for thread safety - Or use
tcpip_callback()for deferred execution from non-lwIP threads
- Custom output function bypasses ARP (like PPP/SLIP)
- Set
netif->flagswithoutNETIF_FLAG_ETHARP
- Packets to 100.x.x.x → Tailscale interface (via subnet match)
- All other traffic → WiFi interface (default route)
- Services on
INADDR_ANYaccessible on both
- Remove synthetic TCP: saves ~2.5KB
- Add TailscaleNetif: ~1KB
- Net savings: ~1.5KB
- ICMP ping -
ping 100.64.0.xfrom another Tailscale node - TCP connection -
nc 100.64.0.x 80then send HTTP request - Dual-interface - Access web server on both WiFi and Tailscale IPs
- Multiple connections - 4+ concurrent TCP connections
- WireGuard handshake - Connection survives peer handshake refresh
- DERP fallback - Works when direct path unavailable
| Challenge | Mitigation |
|---|---|
| Thread safety | Use lwIP locking macros |
| Routing conflicts | Set correct route_prio (50 for Tailscale, 100 for WiFi) |
| ARP-less interface | Use P2P output function, not ethernet output |
| MTU fragmentation | Set MTU=1280 to avoid fragmentation |
Problem: esp_netif_new() creates the ESP-IDF wrapper and underlying lwIP netif, but does NOT insert it into lwIP's netif_list. This caused TCP SYN-ACK packets to be routed to the WiFi interface instead of the Tailscale interface.
Symptom: ICMP ping worked (uses incoming interface), TCP RST worked, but TCP SYN-ACK was sent via WiFi (ip4_output_if: st1 instead of ts0).
Root Cause: ip4_route() searches netif_list to find the output interface. Our netif wasn't in the list.
Fix: Manually insert the netif at the head of netif_list after configuration:
LOCK_TCPIP_CORE();
struct netif* lwip_netif = esp_netif_get_netif_impl(esp_netif_);
if (lwip_netif not in netif_list) {
lwip_netif->next = netif_list;
netif_list = lwip_netif;
}
UNLOCK_TCPIP_CORE();Problem: TCP checksum was calculated over the entire pbuf, which included the IP header.
Fix: Use pbuf_header(p, -(s16_t)IP_HLEN) to hide the IP header before checksum calculation, then restore with pbuf_header(p, IP_HLEN).
Problem: new (std::nothrow) still aborts on allocation failure when ESP-IDF has C++ exceptions disabled.
Fix: Use malloc()/free() instead of new/delete for buffer pools.
Problem: TX_POOL_SIZE=32 × 1536 bytes = ~49KB, exceeding available RAM on ESP32-C3.
Fix: Reduced TX_POOL_SIZE to 4 (~6KB total), sufficient for typical traffic patterns.
| File | Status |
|---|---|
tailscale_netif.h |
✅ Created |
tailscale_netif.cpp |
✅ Created |
tailscale.cpp |
✅ Modified (netif integration) |
tailscale_socket.h/cpp |
Kept for reference (can be deleted) |