<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>P2P on Code is cheap, let&#39;s talk</title>
    <link>https://blog.ferstar.org/en/tags/p2p/</link>
    <description>Code is cheap, let&#39;s talk</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <copyright>© 2026 ferstar · [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)</copyright>
    <lastBuildDate>Fri, 16 Oct 2026 22:30:00 +0800</lastBuildDate>
    <ttl>60</ttl><atom:link href="https://blog.ferstar.org/en/tags/p2p/index.xml" rel="self" type="application/rss+xml" /><image>
      <url>https://blog.ferstar.org/site-logo.png</url>
      <title>Code is cheap, let&#39;s talk</title>
      <link>https://blog.ferstar.org/</link>
    </image>
    
    <item>
      <title>I Rebuilt RustDesk with tailcat for Scan-to-Connect Remote Desktop</title>
      <link>https://blog.ferstar.org/en/posts/yet-another-remote-desktop-application-sealdesk/</link>
      <pubDate>Fri, 16 Oct 2026 22:30:00 +0800</pubDate>
      
      <guid isPermaLink="true">https://blog.ferstar.org/en/posts/yet-another-remote-desktop-application-sealdesk/</guid>
      <description>Self-hosting RustDesk means renting a public VPS to run hbbs and hbbr, and public relays are often throttled or unreliable; SealDesk swaps the transport for tailcat—zero config, no account, no server to set up, scan to connect, P2P-first sessions inside end-to-end encrypted tunnels; the post breaks down the architecture changes, the C-ABI bridge, NAT traversal, and the engineering trade-offs.</description><content:encoded><![CDATA[<blockquote><p>I am not a native English speaker; this article was translated by AI.</p>
</blockquote><p>Yesterday I open-sourced SealDesk: the RustDesk transport swapped for tailcat. The host taps share, the controller scans a code, and it connects.</p>
<p>RustDesk’s public servers aren’t always good, and self-hosting means renting a VPS and running <code>hbbs</code> and <code>hbbr</code>. SealDesk removes that setup, and there’s no account to register either.</p>

<h2 class="relative group">How the Two Ends Find Each Other Without a Server
    <div id="how-the-two-ends-find-each-other-without-a-server" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-the-two-ends-find-each-other-without-a-server" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The trigger for this project was Tailscale open-sourcing <a href="https://github.com/tailscale/tailcat"  target="_blank" rel="noreferrer">tailcat</a>. Tailscale’s NAT traversal and WireGuard mesh are great to use, but the standard setup depends on a coordination server (the hosted SaaS or a self-run Headscale) for authentication, node distribution, and ACLs. tailcat peels the data plane out as a standalone library: no account system, no control plane, just peer-to-peer transport itself.</p>
<p>The <code>tc</code> address is the complete connection credential. On startup, the server generates a Base64 string starting with <code>tc</code>, around 150 characters long; it packages the WireGuard public key, the pre-shared key (PSK), and the DERP region used for rendezvous. Every piece of cryptographic material the two ends need to find and authenticate each other lives in that one string.</p>
<p>The two ends meet on a public DERP node using this address. As soon as STUN/UDP hole punching succeeds, traffic switches to a direct WireGuard UDP tunnel; only in genuinely untraversable setups such as symmetric NAT does DERP fall back to forwarding ciphertext, which it cannot decrypt. That rendezvous relay is Tailscale’s public DERP, so there’s nothing to host, and <code>hbbs</code> / <code>hbbr</code> can be removed outright.</p>

<h2 class="relative group">After the Scan
    <div id="after-the-scan" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#after-the-scan" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The handshake never touches a central server; the pairing code just carries the <code>tc</code> address and temporary password over to the controller:</p>
<pre class="not-prose mermaid">
flowchart TD
    subgraph Host
        H1[Start tailcat Listener] --> H2[Generate tc... Address + Password]
        H2 --> H3[Render SEALDESK: Pairing QR Code]
        H3 --> H4[Wait for DERP Rendezvous Beacon]
    end

    subgraph Controller
        C1[Scan QR / Load Image / Paste Code] --> C2[Parse tc Address & Temporary Password]
        C2 --> C3[Dial tailcat Transport]
    end

    subgraph Handshake & Tunnel
        C3 & H4 --> D1[Rendezvous Handshake via Public DERP]
        D1 --> D2{STUN/UDP Hole Punching}
        D2 -->|Punch Succeeded| D3[Direct P2P WireGuard Tunnel]
        D2 -->|Symmetric NAT| D4[Fallback Encrypted DERP Forwarding]
    end

    D3 & D4 --> R[RustDesk Desktop Session: Auth, Video/Audio Stream, Input Injection]
</pre>

<p>The host opens the app and taps “Share this device”; the pairing QR code sits front and center. The controller scans it with a phone, or on desktop uses the webcam, picks a QR image file, or pastes a screenshot from the clipboard. On the same LAN the two ends find each other without touching the internet—approve the first request at the host, tick “Trust this device,” and it connects straight in next time.</p>
<p>The temporary password can be refreshed and the address reset; old pairing codes stop working once either changes. Stop sharing on either end and every session closes; nothing keeps running in the background.</p>

<h2 class="relative group">The C-ABI Between Go and Rust
    <div id="the-c-abi-between-go-and-rust" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-c-abi-between-go-and-rust" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>tailcat is pure Go; the RustDesk core is Rust. In <code>libs/tailcat-ffi</code>, we wrapped a minimal C-ABI with cgo: the host calls <code>TcServe</code> with the local RustDesk loopback port—every tunnel connection is forwarded to it—and gets back the generated <code>tc...</code> address; the controller calls <code>TcForward</code> with the peer address, which opens a local forward port; connect to it and you’re inside the tunnel. All returned strings are freed through <code>TcFree</code>, so pointers crossing the FFI boundary don’t leak.</p>
<p>Android took an extra step: Go 1.27.1’s <code>-buildmode=c-archive</code> returns “not supported” on android/arm64, so the build switches to c-shared and the Rust side links against <code>libtailcat.so</code> accordingly.</p>

<h2 class="relative group">Bypassing rendezvous_mediator
    <div id="bypassing-rendezvous_mediator" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#bypassing-rendezvous_mediator" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Upstream RustDesk couples its networking tightly to <code>rendezvous_mediator</code>: before connecting, the client queries the central server for the peer’s direct IP list or relay endpoint. In <code>src/client.rs</code>, <code>src/tailcat.rs</code>, and <code>src/client/io_loop.rs</code>, we added a dedicated tailcat branch: whenever the peer is a <code>tc...</code> address, all centralized resolution is skipped and the desktop stream rides the tailcat-encrypted connection. RustDesk’s battle-tested codecs (H.264/H.265/VP9) and input injection pipeline stay untouched.</p>

<h2 class="relative group">Android Drops the Session When the Network Switches
    <div id="android-drops-the-session-when-the-network-switches" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#android-drops-the-session-when-the-network-switches" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Controllers routinely hop between Wi-Fi and cellular, and on a switch the path update can’t keep up, so the session tends to drop. Android also denies netlink to app UIDs, so the Go-side netmon cannot enumerate network interfaces on its own and depends on the app layer to feed them in. In <code>libs/tailcat-ffi/android_interfaces.go</code>, we added <code>TcSetAndroidInterfaces</code>: the Kotlin layer reads interface names and addresses via <code>java.net.NetworkInterface</code>, and every connectivity change pushes them as JSON over JNI, registering them with netmon—so a mobile session doesn’t reconnect on every hop.</p>

<h2 class="relative group">What’s Been Tested
    <div id="whats-been-tested" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#whats-been-tested" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The initial release has exercised these paths: Android (arm64) controlling Linux and Linux controlling Linux, stable on and off the LAN, with low resource usage; on the same LAN it also interoperates with upstream RustDesk in both directions.</p>

<h2 class="relative group">Platforms and Limits
    <div id="platforms-and-limits" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#platforms-and-limits" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Platform support is still Alpha: Linux has been tested as both host and controller, and Android is controller-only for now; Windows and macOS inherit the upstream code, and packaging and driver details aren’t finished.</p>
<p>Interop with upstream RustDesk uses its original direct protocol; that path is unencrypted, and SealDesk warns you before you connect.</p>
<p>SealDesk lives under <a href="https://github.com/runseal-labs"  target="_blank" rel="noreferrer">runseal-labs</a> (the “Seal” in the name is that sealed isolation between the two ends). The repository is at <a href="https://github.com/runseal-labs/sealdesk"  target="_blank" rel="noreferrer">github.com/runseal-labs/sealdesk</a>—if you’re tired of babysitting <code>hbbs</code> and <code>hbbr</code>, give it a spin; issues and PRs are welcome.</p>
]]></content:encoded>
      
    </item>
    
  </channel>
</rss>
