Transport-agnostic, inject-and-play debug/RPC bridge: reliable multiplexed channel + remote execve + port-forward over any byte pipe (serial/vsock/unix/tcp/ws/stdio), via userspace PPP over a kernel TUN.
0
10 commits
updated Oct 5, 2026
A transport-agnostic, inject-and-play debug / RPC bridge — one daemon + one client that give you a reliable, multiplexed control channel, remote execve, and port-forwarding over any byte pipe, even when the pipe isn't reliable.
Status: design / pre-implementation. This README is the design spec. Nothing is built yet.
Every tool that lets you reach into "the thing on the other side of a channel" is welded to one transport and one execution model:
| Tool | Transport | Execution |
|---|---|---|
ssh | TCP | shell / PTY |
adb | USB / TCP | shell + exec-out |
qemu-guest-agent | virtio-serial | guest-exec |
docker exec | unix socket / API | shell / exec |
They solve the same problem — run a process on the far side of a byte channel, forward some ports, maybe get an interactive shell — four times, four ways, none of them interchangeable. And most of them are shellful, which is exactly why ansible and terraform fight their targets: a shell is not an RPC endpoint (quoting hell, rc/profile side-effects corrupting output, needs an interpreter on the target, no clean stdout/exit separation), and neither tool can reach a console-only / serial target at all.
pdbd collapses that into one binary over "everything is a socket":
vsock, unix socket, TCP, a PTY, stdio, a websocket, or any program's stdio — selected with a socat-style address.vsock today" is never an assumption we're allowed to make. pdbd runs PPP in userspace to turn any dumb pipe into a real IP link, and lets the kernel's TCP carry reliability on top.execve, not shell. Structured execve(argv[]) with stdio tunneled and a real exit code; a PTY only when you ask for shell. This is what makes it safe for automation and able to drive serial/console-only hosts the big tools can't.pdbd is transport- and OS-agnostic by design, meant to be reused across projects — it is not built for any single one. The general job is instrumenting and driving Linux images — a VM guest, a container, or a bare-metal host — over whatever channel reaches them, including the awkward ones (a raw serial console, a Serial-over-LAN link) that ssh/adb/docker exec can't touch. The arc is a single bridge that unifies local-admin ssh, adb, guest agents, and docker exec — optimized for infrastructure use, over anything that looks like a socket.
The central idea: pdbd/pdb own L1 (transport) and L2 (link), the kernel owns L3/L4 (IP/TCP/UDP), and pdbd/pdb own L7 (the services). PPP in userspace bridges a dumb L1 pipe up to a kernel IP interface, so everything above L2 is real kernel networking — real sockets, real kernel TCP reliability, real netfilter.
flowchart TB
subgraph app["pdbd / pdb — userspace"]
L7["<b>L7 — services</b><br/>exec · shell · forward · bind · socat · drop · list"]
L2["<b>L2 — PPP (ppproto, userspace)</b><br/>HDLC framing + FCS · LCP (ACCM, echo) · IPCP"]
end
subgraph kern["Linux kernel"]
L34["<b>L3/L4</b> — IP · TCP · UDP · routing · netfilter"]
TUN["TUN device (kernel L3 interface)"]
end
subgraph wire["L1 — transport (assumed UNRELIABLE, pluggable)"]
T["serial · udp · vsock · unix · tcp · pty · stdio · ws · wss · webtransport · exec"]
end
L7 <-->|kernel sockets| L34
L34 <--> TUN
TUN <-->|IP packets| L2
L2 <-->|framed bytes| T
classDef own fill:#1f6feb22,stroke:#1f6feb;
classDef k fill:#8957e522,stroke:#8957e5;
classDef w fill:#3fb95022,stroke:#3fb950;
class L7,L2 own;
class L34,TUN k;
class T w;
A dumb, bidirectional byte pipe. We always treat it as lossy — a transport may be a noisy physical UART or a Serial-over-LAN link that drops bytes, and a factually-reliable transport (a vsock, a unix socket) is never assumed reliable. The transport sits strictly below the tool.
Transports are named with a socat / websocat-style address whose TYPE encodes both the backend and the direction (connect vs listen), so no separate flags are needed:
--socket FILE:/dev/ttyS0,b115200,raw # a physical UART / Serial-over-LAN device
--socket UNIX-CONNECT:/run/vm/guest.sock # a VM host-end chardev socket
--socket VSOCK-CONNECT:3:9000 # vsock (cid:port)
--socket UDP-CONNECT:host:9000 # a lossy datagram pipe (the archetype L1)
--socket WS-LISTEN:0.0.0.0:8080 # websocket (insecure)
--socket WSS-CONNECT:host:443 # websocket-secure (TLS) — see Crypto
--socket WEBTRANSPORT-CONNECT:host:443 # WebTransport (secure, over QUIC/UDP) — see Crypto
--socket EXEC:'ssh jump nc target 23' # ride the link over ANY program's stdio
--socket STDIO # ride stdio
Each TYPE maps to an existing AsyncRead + AsyncWrite backend (tokio TCP/unix/UDP, tokio-serial, tokio-vsock, tokio-tungstenite+ws_stream_tungstenite for ws/wss, wtransport for webtransport), so L1 is largely assembly. EXEC: is the sleeper feature — the link can ride over anything that produces a pipe. WSS and WEBTRANSPORT are the built-in secure transports (see Crypto): a substrate explosion, though possible, is unreasonable for them — their web overhead only pays off with routers between the ends — so their substrate stays stable. The lower-overhead secured pipes (TLS, SSH, raw QUIC) are what exploded traffic actually favors, so they compose externally via EXEC:.
ppproto-style sans-IO PPP runs HDLC framing (+FCS), LCP, and IPCP entirely in userspace. This is the part that makes a lossy serial usable:
The negotiated IP packets terminate into a kernel TUN device — so PPP is done in userspace, but IP and everything above it is the kernel's. This backend needs only CONFIG_TUN (not kernel PPP), which maximizes portability. A kernel-PPP backend (/dev/ppp, kernel-side framing) is a future drop-in for performance: it relocates L2 into the kernel without changing L3/L4, so nothing above it moves.
Because L2 terminates into a TUN, the kernel runs its full stack on the link: real sockets, routing, netfilter, and — crucially — kernel TCP carries reliability over the lossy pipe. A frame corrupted on the wire fails FCS and is dropped; the TCP segment it carried is never ACKed; kernel TCP retransmits it. This is how TCP-over-PPP survived noisy modem lines for decades. One system (kernel TCP) gives us reliability, ordering, flow control, fair-sharing across tunnels, and the connection-teardown signal our cleanup rides on.
Address discovery is free: IPCP conveys each end's address to the other as part of link bring-up (and supports dynamic assignment — the dial-up heritage). So a client learns the daemon's control address from its own completed IPCP, regardless of whether that address was user-assigned or kernel-negotiated. No out-of-band discovery.
Because L2 is PPP terminating into a kernel IP interface, the link is itself a general-purpose PPP network — it provides ordinary L3 (IP) traffic over non-conventional L1 transports, so pdbd's debug tunnels are just one consumer of it and arbitrary IP traffic can ride the same link. Multi-IP, policy-routing, and firewall zones then fall out of the kernel L3 interface for free (ip addr add, ip route, nft). These are latent — not built in v0, but the architecture leaves the door open to carve the single link into firewalled policy zones (control, forward, and the general-purpose network) later.
pdbd is, above the link, an RPC/debug agent. Commands: exec, shell, forward, bind, socat, drop, list.
exec vs shell. exec is a structured execve(argv[]) with stdio tunneled and a real exit code — no shell, no PTY (the automation-friendly primitive, like adb exec-out). shell allocates a PTY (adb shell / ssh -t). Each invocation is a fresh, isolated process, which is what eliminates the dirty-state problem a single shared console would have.The two ends have different process constraints:
pdb (client-side) requires multiprocess. Each invocation is a distinct OS process, central is a separate singleton, and a forward/bind gets its own spun-up pdb worker process (the client/worker/central split is detailed under Topology); they coordinate over local IPC. The invocation model forces this — it cannot collapse to one process.pdbd (daemon-side) is multiworker and does not require multiprocess. Its hard requirement is a connection owner per tunnel (something holds the socket fd and drives it) plus execve children. The execve children do run as separate processes (execve replaces the image); the connection owners need not be — they may be async tasks, threads, or processes. Process-per-tunnel is a choice for crash/exploit isolation (a faulting tunnel can't corrupt the core — sshd-privsep's rationale), not an architectural requirement. An alternative-language pdbd may run a single-process worker pool — and if it instead runs a multiprocess pool, that core↔worker boundary becomes public wire (see Control plane).Common to both ends: a small, long-lived control-plane core owns the link and the control channel, while volatile per-connection work lives in disposable workers (sshd's process-per-connection, adbd's per-stream model). Concurrency within a worker is async (tokio); isolation between tunnels is whatever boundary the impl chose.
Modeled on ssh/adb — client-side vs daemon-side, never "host/guest" (on bare metal there is no host/guest, just two ends of a wire).
flowchart LR
subgraph client["client-side"]
PC["pdb client"]
PW["pdb worker"]
CC["pdb central"]
PC -->|local IPC| CC
PW -->|local IPC| CC
end
subgraph daemon["daemon-side — always-on"]
DC["pdbd central"]
DW1["pdbd worker"]
DW2["pdbd worker"]
DW1 -->|local IPC| DC
DW2 -->|local IPC| DC
end
CC <==>|"PPP link — control channel + every tunnel"| DC
The heavy line is the one PPP link between the two centrals, and it carries everything — the control channel and every data tunnel, each a separate kernel-TCP connection the kernel demuxes by 4-tuple. Ownership and transport are different things. The centrals own the link and transport every tunnel (each end's kernel routes tunnel packets out its TUN, and the central's ppproto pump carries them over the link) — but a central never holds a tunnel's socket. The connection owners do: a pdb client for its exec/shell, a pdb worker for a forward/bind, a pdbd worker on the daemon end. So a tunnel's bytes flow through the centrals' PPP pipe while being owned at the edges. central/worker/client are roles, never commands you type.
pdbd — the daemon. Always on, daemon-side, idles waiting for a peer. The permanent endpoint; its workers own the daemon end of every tunnel.pdb — the client, in three roles:
pdb exec …, pdb forward …): command-and-control plus exec/shell stdio, owning its own exec/shell tunnel. Dies with the command.forward/bind on the client end, mirroring a pdbd worker. Outlives the client that asked for it; lives as long as the tunnel.Orchestration. You only ever invoke a pdb client. On start it finds the running central, or — if none exists — auto-spawns one (exactly as adb auto-starts its background server) and attaches. A forward/bind client asks central to spin up a pdb worker to own the tunnel, then the client may exit; an exec/shell client owns its tunnel itself. Every client and worker shares the one central. There is no pdb central/pdb worker command — both are spawned, discovered, and reaped implicitly.
IPC & transport. A pdb client/worker ↔ central speak over a local IPC socket (UDS) for control. central owns the PPP link + the control channel to pdbd and transports every tunnel over the link — but it is not a socket-owning relay: a tunnel is a kernel-TCP connection whose endpoints are owned by the client/worker and the pdbd worker, while its packets ride central's TUN→PPP pump. So central moves the bytes (as IP over PPP) without ever holding the tunnel's socket or seeing it as an application stream. central is where the link, the control channel, and the tunnel table live; the owners hold the fds. The three control hops and the wire standard they share are detailed under Control plane.
Lifecycle. central's lifetime is refcounted to the tunnel table — not to the number of clients. It exits when the active-tunnel count drops to zero, which brings the link down. A standing forward/bind is owned by a pdb worker that outlives the client which launched it, so it keeps the tunnel table non-empty and central alive; an exec/shell tunnel dies with its client. (An idle-linger grace before exit is an option, to keep a warm link across bursts of activity.)
All TCP — pdbd's debug tunnels and other traffic on the general-purpose PPP network — rides the kernel's TCP/IP over the one PPP link. Two distinct questions, often conflated: who owns a tunnel's socket, and who transports its packets.
flowchart LR
PC["pdb client"]
PW["pdb worker"]
CC["pdb central"]
DC["pdbd central"]
DW["pdbd worker"]
GC["some app (client-side)"]
GD["some app (daemon-side)"]
PC -. "exec/shell TCP ↕ kernel TUN" .-> CC
PW -. "forward/bind TCP ↕ kernel TUN" .-> CC
CC <==>|"PPP link — transports every tunnel"| DC
DC -. "TCP ↕ kernel TUN" .-> DW
GC -. "general-purpose IP — kernel-forwarded, no pdb/pdbd socket" .-> CC
GD -. "general-purpose IP — kernel-forwarded, no pdb/pdbd socket" .-> DC
pdb client for its exec/shell, a pdb worker for a forward/bind, and a pdbd worker on the daemon end. The owner opens its socket from creation — directed over RPC ("accept/connect tunnel-id X"), it does the accept/connect itself. Nothing passes an fd across a boundary, so every boundary stays portable and language-neutral (a cross-language/cross-host worker can't receive a Unix SCM_RIGHTS descriptor). fd-passing (SCM_RIGHTS) survives only as an optional same-host optimization, never part of the wire contract.ppproto pump carries it over the PPP link to the peer central, whose TUN delivers it to the peer owner. So the centrals transport every tunnel (and the control channel) without ever holding a tunnel fd or seeing it as an application stream. Each central also keeps the tunnel table (control + refcount) — accounting, not ownership.pdb/pdbd socket at all — it never enters a tunnel table, rides the same PPP link, and shows up only in the kernel's view (ss / conntrack). Both ends have a TUN, so the PPP network is symmetric: an app on either host — client-side or daemon-side — can put ordinary traffic on the link, not just pdbd's tunnels.Ownership needs no packet inspection — it's just which process holds the fd; transport needs none either — the kernel routes it to the TUN. The kernel does the TCP for everything regardless.
drop as a convenienceWe never trust graceful signals (things die without notice). So the kernel's TCP state is the cleanup oracle: FIN / RST / keepalive-failure on a tunnel → pdbd reactively reaps that command's resources (kill the exec child, remove the forward hole). A drop <tunnel-id> command is the explicit, graceful teardown of a long-runner — it triggers the same reap path early, but it is not the safety net; the kernel is.
If central vanishes without an LCP Terminate (crash) while the pipe stays up, PPP is built to recover — it's the dial-up heritage:
sequenceDiagram
participant C as pdb central
participant D as pdbd (survivor)
Note over C,D: link Opened, tunnels live
C--xD: central crashes (no LCP Terminate)
loop LCP echo
D->>C: Echo-Request
Note over D: no reply × N → link declared dead
end
Note over D: PPP layer-down → SESSION REAP<br/>(kill orphaned exec children,<br/>remove forward holes, close dead conns)
C->>D: central restarts → fresh LCP Configure-Request
Note over D: RCR-in-Opened forces renegotiation<br/>(even if not yet timed out)
C<<->>D: link re-established, clean session
Two native mechanisms do the heavy lifting: LCP echo detects the dead/half-open peer, and a returning peer's Configure-Request forces renegotiation even if the survivor hasn't timed out yet (RFC 1661 RCR-in-Opened). PPP heals the link; pdbd heals the session — it hooks PPP's layer-down event to reap orphaned children, firewall holes, and dead connections, so the returning central meets a clean daemon.
The data plane is multiplexed by the kernel (per-tunnel 4-tuple) and ppproto (frames on the pipe) — settled, and no userspace muxer is pulled for it. What genuinely needs multiplexing is the control plane, which is three segments:
Each carries many small concurrent logical streams (start-exec, tunnel-opened, exit-status, list, drop, keepalive). None of it is hand-rolled: the multiplexer is the RPC framework's own.
pdbd/pdb are a reference implementation. The wire must be a standardized, language-neutral protocol with a published schema, so independent reimplementations — gopdbd/gopdb, jpdbd/jpdb, cpdbd/cpdb — are plug-and-play with each other and with this one. A cpdb ephemeral controlling a gopdb central talking to a jpdbd central driving a Rust pdbd conn-worker must just work. That rules out any language-private RPC (e.g. a Rust-serde framing): the contract is the wire, and the wire is a standard.
This makes segment 3 a public interface, conditionally: a daemon that runs a multiprocess worker pool must speak the standard across it (so a jpdbd central and a Rust conn-worker interoperate); a single-process daemon has no such wire and legitimately opts out. The clean guarantee is one standard service, with central and pdbd-central as routers that forward it — then the worker boundary speaks the identical service for free.
Two protocols satisfy "international standard + built-in mux + first-party implementations in every target language":
| Protocol | Wire | Mux | Multi-language |
|---|---|---|---|
| gRPC (recommended) | HTTP/2 + protobuf (CNCF) | HTTP/2 streams — RFC 9113 | grpc-go, grpc-java, grpc C/C++ core, Python, C#, … — first-party; interop is its whole purpose |
| Cap'n Proto RPC (alternative) | Cap'n Proto wire + rpc.capnp | question/answer-id mux + promise pipelining (in-spec) | C++, Rust, Go, Java, Python |
gRPC is the recommendation — cross-language interop is the solved, boring case, and its mux (HTTP/2) is itself an IETF RFC. Cap'n Proto is the credible alternative (promise pipelining cuts round-trips on a high-latency serial link, and it is lighter than HTTP/2), with thinner multi-language RPC-layer maturity. A Rust-only RPC (tarpc et al.) is disqualified by the interop requirement, whatever its ergonomics.
This is why gRPC/
tonicis not in the rejected pile for the control plane. The HTTP/2 head-of-line concern applies only to carrying tunnel bulk — many high-throughput streams on one connection — which is exactly what the per-tunnel kernel-TCP design keeps off the RPC. Tiny control messages lose nothing to HTTP/2.
The artifact that makes cross-language real is a published, versioned pdbd.proto (or .capnp) defining the service — Exec/Shell/Forward/Bind/List/Drop, message types, streaming semantics. Every implementation codegens from it; it is a published interface contract consumers depend on at a version, not a transient file.
The "control channel" is really two already-international standards on top of each other:
ppproto), RFC 1661 / 1332: establishes the link, negotiates addresses, carries liveness.pdbd.proto, over kernel TCP, which exists only after IPCP brings up IP (so the RPC always rides a reliable stream).The stack is therefore standard wire top to bottom: RFC-1661 PPP → kernel IP/TCP → RFC-9113 HTTP/2 → protobuf. A reimplementer has a spec for every layer.
A userspace mux (yamux, SSH channels, HTTP/2, adb's multiplexing loop) exists to run many logical streams over one connection when the transport has no IP layer. pdbd gives itself an IP layer (PPP → TUN → kernel), so each tunnel is just another kernel socket, demuxed by 4-tuple. The only scenario a data-plane mux would help is TCP-tuple exhaustion — and that is moot: on a point-to-point IPv4 link to one control endpoint only the source port varies (~64 k), but allocating IPv6 (or binding multiple source addresses — which multi-IP-over-PPP already allows — and/or a pdbd holding multiple addresses) makes the tuple space astronomically larger than any host's fd / memory / scheduler budget. You exhaust physical compute long before the tuple pool, so a mux buys nothing the kernel does not already give.
pdbd is already running. pdb central comes up, brings up the PPP link, and learns pdbd's control address from its own IPCP — no fixed address required, no out-of-band discovery.pdbd programs its own firewall hole for the control port (it holds CAP_NET_ADMIN), so it doesn't fight the firewall — it configures it. Everything after the control channel (forwards, exec) is negotiated over the control channel and provisioned on demand.pdbd grows no general-purpose crypto layer. Its stance is approximately socat's — select a secured transport, pass its options through, hold no security policy — with one deliberate departure: socat exposes OpenSSL as a generic wrap over any address, and pdbd has no such generic TLS wrap. The TLS it does ship is bound inside the built-in WSS/WEBTRANSPORT transports (rustls, feature-gated); wrapping an arbitrary L1 in TLS is composed externally (below). (The earlier "we're a trusted-channel debug daemon, so skip crypto" premise fell away once plug-and-play pulled in serious general-administration / IaC use, where a secured hop is a normal requirement — so carrying security is in scope; owning it is not.)
So the only question is which secured transports are built in vs composed externally, and it reduces to one criterion:
Build in a secure transport iff an L3-and-below "substrate explosion," though technically possible, would be unreasonable for it — so its substrate stays stable in practice. The lower-overhead protocols an explosion actually favors stay external.
The test is L3-and-below, not L4 — everyone can assume TLS→TCP and QUIC→UDP; the question is what sits under that. And it is economic, not categorical: an exploded substrate is possible for all four (TLS, QUIC, WSS, WT), but only reasonable for some.
WebTransport is included for the same reason as WSS. It carries the same web-oriented overhead, so an exploded-substrate deployment would drop it for raw QUIC exactly as it drops WSS for raw TLS — its substrate stays stable by the same economics. Raw QUIC has no such overhead to shed, so it is where an explosion lands: external, not built in.
Secure transports are feature-gated — serial/udp/unix/tcp/stdio/exec are always in; wss/webtransport are opt-in cargo features, so the lean trusted-channel core never has to carry a TLS/QUIC dependency tree.
WSS)Primary secure transport. TLS + cert verification come from the WS stack (tokio-tungstenite + rustls); pdbd just selects it as an L1:
pdbd --socket WSS-LISTEN:0.0.0.0:443,cert=server.pem,key=server.key
pdb --socket WSS-CONNECT:gateway.example:443 exec -- uname -a
(PPP/kernel-TCP over a TCP-based WSS is TCP-in-TCP — the standard caveat for any TCP-based L1; see L1.)
WEBTRANSPORT)The secure transport for a QUIC-capable network (needs UDP reachability; wtransport on quinn). A datagram session is the default exposure — encrypted, NAT-traversing UDP, the lossy pipe pdbd is built for, with no reliability doubled under kernel TCP:
pdbd --socket WEBTRANSPORT-LISTEN:0.0.0.0:443,cert=server.pem,key=server.key
pdb --socket WEBTRANSPORT-CONNECT:gateway.example:443 exec -- uname -a
Everything else secures outside the binary, handed to pdbd through the EXEC: escape hatch (or a forwarded local port) — pdbd carries no TLS/SSH/QUIC code. Three ready tools:
OpenSSL — a plain TLS pipe via socat or the openssl binary:
# socat OPENSSL
pdbd --socket EXEC:'socat - OPENSSL-LISTEN:4433,reuseaddr,cert=server.pem,key=server.key,verify=1'
pdb --socket EXEC:'socat - OPENSSL:gateway.example:4433,verify=1' exec -- uname -a
# openssl s_server / s_client
pdbd --socket EXEC:'openssl s_server -quiet -accept 4433 -cert server.pem -key server.key'
pdb --socket EXEC:'openssl s_client -quiet -connect gateway.example:4433' exec -- uname -a
SSH — stdio, port-forward, reverse-forward, or SOCKS5:
# stdio (ssh -W is the clean form; or exec pdbd on the far side)
pdb --socket EXEC:'ssh -W dbhost:4000 jump' exec -- uname -a
pdb --socket EXEC:'ssh host pdbd --stdio' exec -- uname -a
# local forward (-L): forward a port, then ride plain TCP to it
ssh -fN -L 7000:dbhost:4000 jump
pdb --socket TCP:127.0.0.1:7000 exec -- uname -a
# reverse forward (-R): run on the daemon host; pdb then dials 127.0.0.1:7000 client-side
ssh -fN -R 7000:localhost:4000 client-host
# SOCKS5 (-D): proxy, then a SOCKS5-capable dial
ssh -fN -D 1080 jump
pdb --socket EXEC:'ncat --proxy 127.0.0.1:1080 --proxy-type socks5 dbhost 4000' exec -- uname -a
QUIC — a raw QUIC pipe via quicat, the socat-shaped QUIC utility (stdio both ends, so it reads like the ssh -W / openssl s_client cases):
pdbd --socket EXEC:'quicat quic-passive-listen://0.0.0.0:4433 stdio'
pdb --socket EXEC:'quicat stdio quic-active-connect://gateway.example:4433' exec -- uname -a
quicatis experimental (single QUIC session at a time, lightly maintained) — shown as the clean pattern (QUIC stream → stdio → pdbd), not a production pick. Noteopenssl s_client -quicis not a substitute: OpenSSL's QUIC is HTTP/3-oriented (ALPN-mandated), not a raw byte pipe. For real traffic prefer a maintained QUIC tunnel (e.g.ombrac, TCP/UDP-over-QUIC) exposing a local port pdbd rides.
On a trusted point-to-point pipe (local serial, vsock, unix socket) crypto is pure overhead and is simply omitted — the canonical pdbd deployment:
pdbd --socket FILE:/dev/ttyS0,b115200,raw
pdb --socket FILE:/dev/ttyUSB0,b115200,raw exec -- uname -a
So: pdbd secures a WAN hop with built-in WSS / WebTransport, secures an arbitrary pipe with external OpenSSL / SSH / QUIC via EXEC:, and runs bare on a trusted channel — never growing a general-purpose crypto layer.
No single tool does what pdbd does — transport-agnostic remote exec and a general IP path over the same arbitrary byte pipe — but each half is well-trodden. Surveyed across languages (not just the C/Rust systems world) so we steal the right grammar rather than reinvent it.
PPP, in userspace — the L2 we need:
ppproto (Rust) — no-std, no-alloc, sans-IO PPP implementing RFC 1661 (LCP) + RFC 1332 (IPCP), tested against pppd. Our starting point for L2 — sans-IO is exactly the shape that lets us feed it any transport.zouppp (Go) — userspace PPP/PPPoE client with its own LCP/IPCP/IPv6CP state machines; the cleanest cross-language cross-check for our control-protocol logic.pppd (C) — the canonical reference for driving kernel PPP (GPLv2; we reimplement the grammar/behavior, never vendor the code, so pdbd stays permissively licensed).Userspace IP — the road not taken. We terminate IP in the kernel via a TUN device (real sockets, kernel TCP reliability). The alternative — a userspace TCP/IP stack — is proven but heavier: gVisor netstack (Go) and smoltcp (Rust). Recorded as the explicit fork in the design, not an oversight.
Transport-agnostic remote exec / RPC — the L7 we need:
u-root's cpu (Go) — plan9-cpu-inspired remote exec that carries namespaces over a flexible transport; closest in spirit to the exec half.gokrazy/breakglass (Go) — inject a static binary into an otherwise-immutable appliance and get an interactive debug shell; the "break glass into a sealed image" use-case, which is exactly ours.eRPC / EmbeddedRPC (C/C++) — RPC explicitly decoupled from transport (serial, TCP, USB, RPMsg); the strongest prior art for one RPC surface over many byte pipes.citizenshell (Python) — one shell API over telnet / ssh / serial / adb; the clearest statement of the unification goal pdbd chases, from the scripting world.L1 address grammar:
websocat / socat — socat-style address specifiers; the dialect reference for pdbd's --socket L1 addresses (grammar reimplemented, never copied from GPL socat).Control-plane wire & mux standards — what the interop requirement draws on:
quinn) — the mux + connection-migration reference designs. QUIC-as-the-core-transport was weighed and set aside: it bundles mux + reliability + crypto that pdbd already gets from the kernel + PPP, and (like TLS) it is substrate-flexible, so it stays an external secured L1 (a QUIC tunnel / quicat via EXEC:), never a built-in. Its migration design is still the comparison point for our PPP recovery (below). WebTransport — QUIC wearing an OSI-bound web architecture — is built in; raw QUIC is not (see Crypto).yamux (libp2p) — a mature userspace stream multiplexer; the thing we don't pull, because the kernel IP layer makes it unnecessary.Multiplexing daemon & privilege separation — the process-model prior art:
adb — one binary is client and server, binds localhost:5037, auto-starts the server if absent, refcounts, and is "one giant multiplexing loop." The direct model for pdb central's singleton / auto-spawn / refcount lifecycle.Roaming / recovery from a dead peer:
Port-forward / tunnel fleet — the forward/bind prior art:
russh (Rust) — exposes direct-tcpip/forward-tcpip + unix-socket forwarding; the embeddable-SSH reference for the forwarding primitives.pdbd aims to be).The tools this unifies: adb, ssh, qemu-guest-agent, docker exec — each solves one transport or one capability; pdbd is the single endpoint that spans them.
Candidate Rust dependencies, by layer — versions verified against crates.io on 2026-10-04.
L2 — PPP:
ppproto 0.2.1 — sans-IO PPP state machine (LCP + IPCP). Primary L2 engine. HDLC framing + FCS are internal to it; a standalone hdlc 0.4.1 is the fallback only if we drive framing ourselves.L3 — TUN device:
tun-rs 2.8.11 — cross-platform TUN/TAP, async-capable; broadest device support. Preferred.tun 0.8.14 / tokio-tun 0.15.2 — leaner Linux-first alternatives if we don't need the portability surface.L1 — transports (pluggable):
tokio-serial 5.5.0 (async, over mio-serial 5.0.7 / serialport 4.10.1) — the v0 transport.tokio-vsock 0.7.2 — the VM-guest transport.tokio's UdpSocket with a datagram framing — a lossy datagram L1 (no extra crate); the archetypal pipe PPP + kernel-TCP is designed to recover over.ws/wss): tokio-tungstenite 0.30.0 (establishes ws:// and, with tokio-rustls 0.26.6, wss://) + ws_stream_tungstenite 0.15.0 (adapts the WebSocket to AsyncRead/AsyncWrite). wss is the primary built-in secure transport (see Crypto). Feature-gated.webtransport, built-in secure): wtransport 0.7.2 — WebTransport over HTTP/3 on quinn; built in because its web architecture is OSI-bound (see Crypto), a datagram session the default exposure. Needs UDP reachability. Feature-gated.Kernel plumbing:
rtnetlink 0.23.0 — program routes/addresses on the TUN from the IPCP-negotiated values, without shelling out to ip.Control plane — RPC (language-neutral; see Control plane):
tonic 0.14.6 — gRPC/HTTP-2; recommended for the three control segments. Its HTTP/2 stream mux is the control-plane multiplexer, and the wire is a CNCF standard with first-party implementations in every target language.capnp-rpc 0.27.0 — Cap'n Proto RPC; the alternative (promise pipelining, lighter than HTTP/2).Process model (daemon workers + exec):
nix 0.31.3 — fork/execve/waitpid and the raw syscalls the worker + exec model needs.shell): portable-pty 0.9.0 (wezterm, cross-platform, mature) or pty-process 0.5.3 (tokio-native).interprocess 2.4.4 — async cross-platform local IPC (UDS + named pipes) for the ephemeral↔central socket, if not reusing the RPC lib's own UDS transport.sendfd 0.4.5 / anchovy 0.4.1 (async) / command-fds 0.3.3 — SCM_RIGHTS fd-passing, for the optional same-host tunnel-handoff optimization only (not the portable default; see Tunnel ownership).Considered and rejected:
yamux 0.14.1 / tokio-yamux 0.3.20 — userspace stream multiplexer. Unnecessary: the kernel IP layer multiplexes the data plane by 4-tuple, and the RPC framework multiplexes the control plane. We never carry many logical streams over one connection ourselves.pdbd already gets from kernel + PPP, and it is substrate-flexible (prone to the same L3 "explosion" as TLS), so it stays external — a QUIC tunnel / quicat via EXEC: (see Crypto). quinn 0.11.12 still rides in transitively under wtransport for WebTransport.tarpc — Rust-/serde-private RPC; disqualified by the interop requirement (no language-neutral wire a gopdb/jpdb could target).v0 (the core): a daemon + client, userspace-PPP + TUN over a serial transport, the control channel, and exec + forward.
Later: kernel-PPP backend, udp/vsock/ws transports, the feature-gated built-in secure transports (wss, webtransport), multi-IP zones on the general-purpose PPP network, pluggable auth — and the broader ssh/adb/docker-unification arc.
Interoperability (a first-class goal, not an afterthought): a published, versioned pdbd.proto is the cross-language contract. Independent reimplementations — gopdbd/gopdb, jpdbd/jpdb, cpdbd/cpdb — are meant to be plug-and-play with this reference impl and each other, mixing freely across the three control segments (see Control plane). An alternative daemon may skip a multiprocess worker pool; if it keeps one, that boundary must speak the standard wire.
Per-deployment: environment specifics — e.g. the guest kernel's CONFIG_TUN, or whatever a mandatory-access-control policy on an enforcing host must grant the daemon so it can create its TUN and program netfilter — are a property of each use case, not of pdbd itself.
MIT — see LICENSE. The socat-style address grammar is reimplemented, never copied from GPL socat (an interface/grammar isn't copyrightable; the implementation is).
Transport-agnostic, inject-and-play debug/RPC bridge: reliable multiplexed channel + remote execve + port-forward over any byte pipe (serial/vsock/unix/tcp/ws/stdio), via userspace PPP over a kernel TUN.
0
10 commits
updated Oct 5, 2026
A transport-agnostic, inject-and-play debug / RPC bridge — one daemon + one client that give you a reliable, multiplexed control channel, remote execve, and port-forwarding over any byte pipe, even when the pipe isn't reliable.
Status: design / pre-implementation. This README is the design spec. Nothing is built yet.
Every tool that lets you reach into "the thing on the other side of a channel" is welded to one transport and one execution model:
| Tool | Transport | Execution |
|---|---|---|
ssh | TCP | shell / PTY |
adb | USB / TCP | shell + exec-out |
qemu-guest-agent | virtio-serial | guest-exec |
docker exec | unix socket / API | shell / exec |
They solve the same problem — run a process on the far side of a byte channel, forward some ports, maybe get an interactive shell — four times, four ways, none of them interchangeable. And most of them are shellful, which is exactly why ansible and terraform fight their targets: a shell is not an RPC endpoint (quoting hell, rc/profile side-effects corrupting output, needs an interpreter on the target, no clean stdout/exit separation), and neither tool can reach a console-only / serial target at all.
pdbd collapses that into one binary over "everything is a socket":
vsock, unix socket, TCP, a PTY, stdio, a websocket, or any program's stdio — selected with a socat-style address.vsock today" is never an assumption we're allowed to make. pdbd runs PPP in userspace to turn any dumb pipe into a real IP link, and lets the kernel's TCP carry reliability on top.execve, not shell. Structured execve(argv[]) with stdio tunneled and a real exit code; a PTY only when you ask for shell. This is what makes it safe for automation and able to drive serial/console-only hosts the big tools can't.pdbd is transport- and OS-agnostic by design, meant to be reused across projects — it is not built for any single one. The general job is instrumenting and driving Linux images — a VM guest, a container, or a bare-metal host — over whatever channel reaches them, including the awkward ones (a raw serial console, a Serial-over-LAN link) that ssh/adb/docker exec can't touch. The arc is a single bridge that unifies local-admin ssh, adb, guest agents, and docker exec — optimized for infrastructure use, over anything that looks like a socket.
The central idea: pdbd/pdb own L1 (transport) and L2 (link), the kernel owns L3/L4 (IP/TCP/UDP), and pdbd/pdb own L7 (the services). PPP in userspace bridges a dumb L1 pipe up to a kernel IP interface, so everything above L2 is real kernel networking — real sockets, real kernel TCP reliability, real netfilter.
flowchart TB
subgraph app["pdbd / pdb — userspace"]
L7["<b>L7 — services</b><br/>exec · shell · forward · bind · socat · drop · list"]
L2["<b>L2 — PPP (ppproto, userspace)</b><br/>HDLC framing + FCS · LCP (ACCM, echo) · IPCP"]
end
subgraph kern["Linux kernel"]
L34["<b>L3/L4</b> — IP · TCP · UDP · routing · netfilter"]
TUN["TUN device (kernel L3 interface)"]
end
subgraph wire["L1 — transport (assumed UNRELIABLE, pluggable)"]
T["serial · udp · vsock · unix · tcp · pty · stdio · ws · wss · webtransport · exec"]
end
L7 <-->|kernel sockets| L34
L34 <--> TUN
TUN <-->|IP packets| L2
L2 <-->|framed bytes| T
classDef own fill:#1f6feb22,stroke:#1f6feb;
classDef k fill:#8957e522,stroke:#8957e5;
classDef w fill:#3fb95022,stroke:#3fb950;
class L7,L2 own;
class L34,TUN k;
class T w;
A dumb, bidirectional byte pipe. We always treat it as lossy — a transport may be a noisy physical UART or a Serial-over-LAN link that drops bytes, and a factually-reliable transport (a vsock, a unix socket) is never assumed reliable. The transport sits strictly below the tool.
Transports are named with a socat / websocat-style address whose TYPE encodes both the backend and the direction (connect vs listen), so no separate flags are needed:
--socket FILE:/dev/ttyS0,b115200,raw # a physical UART / Serial-over-LAN device
--socket UNIX-CONNECT:/run/vm/guest.sock # a VM host-end chardev socket
--socket VSOCK-CONNECT:3:9000 # vsock (cid:port)
--socket UDP-CONNECT:host:9000 # a lossy datagram pipe (the archetype L1)
--socket WS-LISTEN:0.0.0.0:8080 # websocket (insecure)
--socket WSS-CONNECT:host:443 # websocket-secure (TLS) — see Crypto
--socket WEBTRANSPORT-CONNECT:host:443 # WebTransport (secure, over QUIC/UDP) — see Crypto
--socket EXEC:'ssh jump nc target 23' # ride the link over ANY program's stdio
--socket STDIO # ride stdio
Each TYPE maps to an existing AsyncRead + AsyncWrite backend (tokio TCP/unix/UDP, tokio-serial, tokio-vsock, tokio-tungstenite+ws_stream_tungstenite for ws/wss, wtransport for webtransport), so L1 is largely assembly. EXEC: is the sleeper feature — the link can ride over anything that produces a pipe. WSS and WEBTRANSPORT are the built-in secure transports (see Crypto): a substrate explosion, though possible, is unreasonable for them — their web overhead only pays off with routers between the ends — so their substrate stays stable. The lower-overhead secured pipes (TLS, SSH, raw QUIC) are what exploded traffic actually favors, so they compose externally via EXEC:.
ppproto-style sans-IO PPP runs HDLC framing (+FCS), LCP, and IPCP entirely in userspace. This is the part that makes a lossy serial usable:
The negotiated IP packets terminate into a kernel TUN device — so PPP is done in userspace, but IP and everything above it is the kernel's. This backend needs only CONFIG_TUN (not kernel PPP), which maximizes portability. A kernel-PPP backend (/dev/ppp, kernel-side framing) is a future drop-in for performance: it relocates L2 into the kernel without changing L3/L4, so nothing above it moves.
Because L2 terminates into a TUN, the kernel runs its full stack on the link: real sockets, routing, netfilter, and — crucially — kernel TCP carries reliability over the lossy pipe. A frame corrupted on the wire fails FCS and is dropped; the TCP segment it carried is never ACKed; kernel TCP retransmits it. This is how TCP-over-PPP survived noisy modem lines for decades. One system (kernel TCP) gives us reliability, ordering, flow control, fair-sharing across tunnels, and the connection-teardown signal our cleanup rides on.
Address discovery is free: IPCP conveys each end's address to the other as part of link bring-up (and supports dynamic assignment — the dial-up heritage). So a client learns the daemon's control address from its own completed IPCP, regardless of whether that address was user-assigned or kernel-negotiated. No out-of-band discovery.
Because L2 is PPP terminating into a kernel IP interface, the link is itself a general-purpose PPP network — it provides ordinary L3 (IP) traffic over non-conventional L1 transports, so pdbd's debug tunnels are just one consumer of it and arbitrary IP traffic can ride the same link. Multi-IP, policy-routing, and firewall zones then fall out of the kernel L3 interface for free (ip addr add, ip route, nft). These are latent — not built in v0, but the architecture leaves the door open to carve the single link into firewalled policy zones (control, forward, and the general-purpose network) later.
pdbd is, above the link, an RPC/debug agent. Commands: exec, shell, forward, bind, socat, drop, list.
exec vs shell. exec is a structured execve(argv[]) with stdio tunneled and a real exit code — no shell, no PTY (the automation-friendly primitive, like adb exec-out). shell allocates a PTY (adb shell / ssh -t). Each invocation is a fresh, isolated process, which is what eliminates the dirty-state problem a single shared console would have.The two ends have different process constraints:
pdb (client-side) requires multiprocess. Each invocation is a distinct OS process, central is a separate singleton, and a forward/bind gets its own spun-up pdb worker process (the client/worker/central split is detailed under Topology); they coordinate over local IPC. The invocation model forces this — it cannot collapse to one process.pdbd (daemon-side) is multiworker and does not require multiprocess. Its hard requirement is a connection owner per tunnel (something holds the socket fd and drives it) plus execve children. The execve children do run as separate processes (execve replaces the image); the connection owners need not be — they may be async tasks, threads, or processes. Process-per-tunnel is a choice for crash/exploit isolation (a faulting tunnel can't corrupt the core — sshd-privsep's rationale), not an architectural requirement. An alternative-language pdbd may run a single-process worker pool — and if it instead runs a multiprocess pool, that core↔worker boundary becomes public wire (see Control plane).Common to both ends: a small, long-lived control-plane core owns the link and the control channel, while volatile per-connection work lives in disposable workers (sshd's process-per-connection, adbd's per-stream model). Concurrency within a worker is async (tokio); isolation between tunnels is whatever boundary the impl chose.
Modeled on ssh/adb — client-side vs daemon-side, never "host/guest" (on bare metal there is no host/guest, just two ends of a wire).
flowchart LR
subgraph client["client-side"]
PC["pdb client"]
PW["pdb worker"]
CC["pdb central"]
PC -->|local IPC| CC
PW -->|local IPC| CC
end
subgraph daemon["daemon-side — always-on"]
DC["pdbd central"]
DW1["pdbd worker"]
DW2["pdbd worker"]
DW1 -->|local IPC| DC
DW2 -->|local IPC| DC
end
CC <==>|"PPP link — control channel + every tunnel"| DC
The heavy line is the one PPP link between the two centrals, and it carries everything — the control channel and every data tunnel, each a separate kernel-TCP connection the kernel demuxes by 4-tuple. Ownership and transport are different things. The centrals own the link and transport every tunnel (each end's kernel routes tunnel packets out its TUN, and the central's ppproto pump carries them over the link) — but a central never holds a tunnel's socket. The connection owners do: a pdb client for its exec/shell, a pdb worker for a forward/bind, a pdbd worker on the daemon end. So a tunnel's bytes flow through the centrals' PPP pipe while being owned at the edges. central/worker/client are roles, never commands you type.
pdbd — the daemon. Always on, daemon-side, idles waiting for a peer. The permanent endpoint; its workers own the daemon end of every tunnel.pdb — the client, in three roles:
pdb exec …, pdb forward …): command-and-control plus exec/shell stdio, owning its own exec/shell tunnel. Dies with the command.forward/bind on the client end, mirroring a pdbd worker. Outlives the client that asked for it; lives as long as the tunnel.Orchestration. You only ever invoke a pdb client. On start it finds the running central, or — if none exists — auto-spawns one (exactly as adb auto-starts its background server) and attaches. A forward/bind client asks central to spin up a pdb worker to own the tunnel, then the client may exit; an exec/shell client owns its tunnel itself. Every client and worker shares the one central. There is no pdb central/pdb worker command — both are spawned, discovered, and reaped implicitly.
IPC & transport. A pdb client/worker ↔ central speak over a local IPC socket (UDS) for control. central owns the PPP link + the control channel to pdbd and transports every tunnel over the link — but it is not a socket-owning relay: a tunnel is a kernel-TCP connection whose endpoints are owned by the client/worker and the pdbd worker, while its packets ride central's TUN→PPP pump. So central moves the bytes (as IP over PPP) without ever holding the tunnel's socket or seeing it as an application stream. central is where the link, the control channel, and the tunnel table live; the owners hold the fds. The three control hops and the wire standard they share are detailed under Control plane.
Lifecycle. central's lifetime is refcounted to the tunnel table — not to the number of clients. It exits when the active-tunnel count drops to zero, which brings the link down. A standing forward/bind is owned by a pdb worker that outlives the client which launched it, so it keeps the tunnel table non-empty and central alive; an exec/shell tunnel dies with its client. (An idle-linger grace before exit is an option, to keep a warm link across bursts of activity.)
All TCP — pdbd's debug tunnels and other traffic on the general-purpose PPP network — rides the kernel's TCP/IP over the one PPP link. Two distinct questions, often conflated: who owns a tunnel's socket, and who transports its packets.
flowchart LR
PC["pdb client"]
PW["pdb worker"]
CC["pdb central"]
DC["pdbd central"]
DW["pdbd worker"]
GC["some app (client-side)"]
GD["some app (daemon-side)"]
PC -. "exec/shell TCP ↕ kernel TUN" .-> CC
PW -. "forward/bind TCP ↕ kernel TUN" .-> CC
CC <==>|"PPP link — transports every tunnel"| DC
DC -. "TCP ↕ kernel TUN" .-> DW
GC -. "general-purpose IP — kernel-forwarded, no pdb/pdbd socket" .-> CC
GD -. "general-purpose IP — kernel-forwarded, no pdb/pdbd socket" .-> DC
pdb client for its exec/shell, a pdb worker for a forward/bind, and a pdbd worker on the daemon end. The owner opens its socket from creation — directed over RPC ("accept/connect tunnel-id X"), it does the accept/connect itself. Nothing passes an fd across a boundary, so every boundary stays portable and language-neutral (a cross-language/cross-host worker can't receive a Unix SCM_RIGHTS descriptor). fd-passing (SCM_RIGHTS) survives only as an optional same-host optimization, never part of the wire contract.ppproto pump carries it over the PPP link to the peer central, whose TUN delivers it to the peer owner. So the centrals transport every tunnel (and the control channel) without ever holding a tunnel fd or seeing it as an application stream. Each central also keeps the tunnel table (control + refcount) — accounting, not ownership.pdb/pdbd socket at all — it never enters a tunnel table, rides the same PPP link, and shows up only in the kernel's view (ss / conntrack). Both ends have a TUN, so the PPP network is symmetric: an app on either host — client-side or daemon-side — can put ordinary traffic on the link, not just pdbd's tunnels.Ownership needs no packet inspection — it's just which process holds the fd; transport needs none either — the kernel routes it to the TUN. The kernel does the TCP for everything regardless.
drop as a convenienceWe never trust graceful signals (things die without notice). So the kernel's TCP state is the cleanup oracle: FIN / RST / keepalive-failure on a tunnel → pdbd reactively reaps that command's resources (kill the exec child, remove the forward hole). A drop <tunnel-id> command is the explicit, graceful teardown of a long-runner — it triggers the same reap path early, but it is not the safety net; the kernel is.
If central vanishes without an LCP Terminate (crash) while the pipe stays up, PPP is built to recover — it's the dial-up heritage:
sequenceDiagram
participant C as pdb central
participant D as pdbd (survivor)
Note over C,D: link Opened, tunnels live
C--xD: central crashes (no LCP Terminate)
loop LCP echo
D->>C: Echo-Request
Note over D: no reply × N → link declared dead
end
Note over D: PPP layer-down → SESSION REAP<br/>(kill orphaned exec children,<br/>remove forward holes, close dead conns)
C->>D: central restarts → fresh LCP Configure-Request
Note over D: RCR-in-Opened forces renegotiation<br/>(even if not yet timed out)
C<<->>D: link re-established, clean session
Two native mechanisms do the heavy lifting: LCP echo detects the dead/half-open peer, and a returning peer's Configure-Request forces renegotiation even if the survivor hasn't timed out yet (RFC 1661 RCR-in-Opened). PPP heals the link; pdbd heals the session — it hooks PPP's layer-down event to reap orphaned children, firewall holes, and dead connections, so the returning central meets a clean daemon.
The data plane is multiplexed by the kernel (per-tunnel 4-tuple) and ppproto (frames on the pipe) — settled, and no userspace muxer is pulled for it. What genuinely needs multiplexing is the control plane, which is three segments:
Each carries many small concurrent logical streams (start-exec, tunnel-opened, exit-status, list, drop, keepalive). None of it is hand-rolled: the multiplexer is the RPC framework's own.
pdbd/pdb are a reference implementation. The wire must be a standardized, language-neutral protocol with a published schema, so independent reimplementations — gopdbd/gopdb, jpdbd/jpdb, cpdbd/cpdb — are plug-and-play with each other and with this one. A cpdb ephemeral controlling a gopdb central talking to a jpdbd central driving a Rust pdbd conn-worker must just work. That rules out any language-private RPC (e.g. a Rust-serde framing): the contract is the wire, and the wire is a standard.
This makes segment 3 a public interface, conditionally: a daemon that runs a multiprocess worker pool must speak the standard across it (so a jpdbd central and a Rust conn-worker interoperate); a single-process daemon has no such wire and legitimately opts out. The clean guarantee is one standard service, with central and pdbd-central as routers that forward it — then the worker boundary speaks the identical service for free.
Two protocols satisfy "international standard + built-in mux + first-party implementations in every target language":
| Protocol | Wire | Mux | Multi-language |
|---|---|---|---|
| gRPC (recommended) | HTTP/2 + protobuf (CNCF) | HTTP/2 streams — RFC 9113 | grpc-go, grpc-java, grpc C/C++ core, Python, C#, … — first-party; interop is its whole purpose |
| Cap'n Proto RPC (alternative) | Cap'n Proto wire + rpc.capnp | question/answer-id mux + promise pipelining (in-spec) | C++, Rust, Go, Java, Python |
gRPC is the recommendation — cross-language interop is the solved, boring case, and its mux (HTTP/2) is itself an IETF RFC. Cap'n Proto is the credible alternative (promise pipelining cuts round-trips on a high-latency serial link, and it is lighter than HTTP/2), with thinner multi-language RPC-layer maturity. A Rust-only RPC (tarpc et al.) is disqualified by the interop requirement, whatever its ergonomics.
This is why gRPC/
tonicis not in the rejected pile for the control plane. The HTTP/2 head-of-line concern applies only to carrying tunnel bulk — many high-throughput streams on one connection — which is exactly what the per-tunnel kernel-TCP design keeps off the RPC. Tiny control messages lose nothing to HTTP/2.
The artifact that makes cross-language real is a published, versioned pdbd.proto (or .capnp) defining the service — Exec/Shell/Forward/Bind/List/Drop, message types, streaming semantics. Every implementation codegens from it; it is a published interface contract consumers depend on at a version, not a transient file.
The "control channel" is really two already-international standards on top of each other:
ppproto), RFC 1661 / 1332: establishes the link, negotiates addresses, carries liveness.pdbd.proto, over kernel TCP, which exists only after IPCP brings up IP (so the RPC always rides a reliable stream).The stack is therefore standard wire top to bottom: RFC-1661 PPP → kernel IP/TCP → RFC-9113 HTTP/2 → protobuf. A reimplementer has a spec for every layer.
A userspace mux (yamux, SSH channels, HTTP/2, adb's multiplexing loop) exists to run many logical streams over one connection when the transport has no IP layer. pdbd gives itself an IP layer (PPP → TUN → kernel), so each tunnel is just another kernel socket, demuxed by 4-tuple. The only scenario a data-plane mux would help is TCP-tuple exhaustion — and that is moot: on a point-to-point IPv4 link to one control endpoint only the source port varies (~64 k), but allocating IPv6 (or binding multiple source addresses — which multi-IP-over-PPP already allows — and/or a pdbd holding multiple addresses) makes the tuple space astronomically larger than any host's fd / memory / scheduler budget. You exhaust physical compute long before the tuple pool, so a mux buys nothing the kernel does not already give.
pdbd is already running. pdb central comes up, brings up the PPP link, and learns pdbd's control address from its own IPCP — no fixed address required, no out-of-band discovery.pdbd programs its own firewall hole for the control port (it holds CAP_NET_ADMIN), so it doesn't fight the firewall — it configures it. Everything after the control channel (forwards, exec) is negotiated over the control channel and provisioned on demand.pdbd grows no general-purpose crypto layer. Its stance is approximately socat's — select a secured transport, pass its options through, hold no security policy — with one deliberate departure: socat exposes OpenSSL as a generic wrap over any address, and pdbd has no such generic TLS wrap. The TLS it does ship is bound inside the built-in WSS/WEBTRANSPORT transports (rustls, feature-gated); wrapping an arbitrary L1 in TLS is composed externally (below). (The earlier "we're a trusted-channel debug daemon, so skip crypto" premise fell away once plug-and-play pulled in serious general-administration / IaC use, where a secured hop is a normal requirement — so carrying security is in scope; owning it is not.)
So the only question is which secured transports are built in vs composed externally, and it reduces to one criterion:
Build in a secure transport iff an L3-and-below "substrate explosion," though technically possible, would be unreasonable for it — so its substrate stays stable in practice. The lower-overhead protocols an explosion actually favors stay external.
The test is L3-and-below, not L4 — everyone can assume TLS→TCP and QUIC→UDP; the question is what sits under that. And it is economic, not categorical: an exploded substrate is possible for all four (TLS, QUIC, WSS, WT), but only reasonable for some.
WebTransport is included for the same reason as WSS. It carries the same web-oriented overhead, so an exploded-substrate deployment would drop it for raw QUIC exactly as it drops WSS for raw TLS — its substrate stays stable by the same economics. Raw QUIC has no such overhead to shed, so it is where an explosion lands: external, not built in.
Secure transports are feature-gated — serial/udp/unix/tcp/stdio/exec are always in; wss/webtransport are opt-in cargo features, so the lean trusted-channel core never has to carry a TLS/QUIC dependency tree.
WSS)Primary secure transport. TLS + cert verification come from the WS stack (tokio-tungstenite + rustls); pdbd just selects it as an L1:
pdbd --socket WSS-LISTEN:0.0.0.0:443,cert=server.pem,key=server.key
pdb --socket WSS-CONNECT:gateway.example:443 exec -- uname -a
(PPP/kernel-TCP over a TCP-based WSS is TCP-in-TCP — the standard caveat for any TCP-based L1; see L1.)
WEBTRANSPORT)The secure transport for a QUIC-capable network (needs UDP reachability; wtransport on quinn). A datagram session is the default exposure — encrypted, NAT-traversing UDP, the lossy pipe pdbd is built for, with no reliability doubled under kernel TCP:
pdbd --socket WEBTRANSPORT-LISTEN:0.0.0.0:443,cert=server.pem,key=server.key
pdb --socket WEBTRANSPORT-CONNECT:gateway.example:443 exec -- uname -a
Everything else secures outside the binary, handed to pdbd through the EXEC: escape hatch (or a forwarded local port) — pdbd carries no TLS/SSH/QUIC code. Three ready tools:
OpenSSL — a plain TLS pipe via socat or the openssl binary:
# socat OPENSSL
pdbd --socket EXEC:'socat - OPENSSL-LISTEN:4433,reuseaddr,cert=server.pem,key=server.key,verify=1'
pdb --socket EXEC:'socat - OPENSSL:gateway.example:4433,verify=1' exec -- uname -a
# openssl s_server / s_client
pdbd --socket EXEC:'openssl s_server -quiet -accept 4433 -cert server.pem -key server.key'
pdb --socket EXEC:'openssl s_client -quiet -connect gateway.example:4433' exec -- uname -a
SSH — stdio, port-forward, reverse-forward, or SOCKS5:
# stdio (ssh -W is the clean form; or exec pdbd on the far side)
pdb --socket EXEC:'ssh -W dbhost:4000 jump' exec -- uname -a
pdb --socket EXEC:'ssh host pdbd --stdio' exec -- uname -a
# local forward (-L): forward a port, then ride plain TCP to it
ssh -fN -L 7000:dbhost:4000 jump
pdb --socket TCP:127.0.0.1:7000 exec -- uname -a
# reverse forward (-R): run on the daemon host; pdb then dials 127.0.0.1:7000 client-side
ssh -fN -R 7000:localhost:4000 client-host
# SOCKS5 (-D): proxy, then a SOCKS5-capable dial
ssh -fN -D 1080 jump
pdb --socket EXEC:'ncat --proxy 127.0.0.1:1080 --proxy-type socks5 dbhost 4000' exec -- uname -a
QUIC — a raw QUIC pipe via quicat, the socat-shaped QUIC utility (stdio both ends, so it reads like the ssh -W / openssl s_client cases):
pdbd --socket EXEC:'quicat quic-passive-listen://0.0.0.0:4433 stdio'
pdb --socket EXEC:'quicat stdio quic-active-connect://gateway.example:4433' exec -- uname -a
quicatis experimental (single QUIC session at a time, lightly maintained) — shown as the clean pattern (QUIC stream → stdio → pdbd), not a production pick. Noteopenssl s_client -quicis not a substitute: OpenSSL's QUIC is HTTP/3-oriented (ALPN-mandated), not a raw byte pipe. For real traffic prefer a maintained QUIC tunnel (e.g.ombrac, TCP/UDP-over-QUIC) exposing a local port pdbd rides.
On a trusted point-to-point pipe (local serial, vsock, unix socket) crypto is pure overhead and is simply omitted — the canonical pdbd deployment:
pdbd --socket FILE:/dev/ttyS0,b115200,raw
pdb --socket FILE:/dev/ttyUSB0,b115200,raw exec -- uname -a
So: pdbd secures a WAN hop with built-in WSS / WebTransport, secures an arbitrary pipe with external OpenSSL / SSH / QUIC via EXEC:, and runs bare on a trusted channel — never growing a general-purpose crypto layer.
No single tool does what pdbd does — transport-agnostic remote exec and a general IP path over the same arbitrary byte pipe — but each half is well-trodden. Surveyed across languages (not just the C/Rust systems world) so we steal the right grammar rather than reinvent it.
PPP, in userspace — the L2 we need:
ppproto (Rust) — no-std, no-alloc, sans-IO PPP implementing RFC 1661 (LCP) + RFC 1332 (IPCP), tested against pppd. Our starting point for L2 — sans-IO is exactly the shape that lets us feed it any transport.zouppp (Go) — userspace PPP/PPPoE client with its own LCP/IPCP/IPv6CP state machines; the cleanest cross-language cross-check for our control-protocol logic.pppd (C) — the canonical reference for driving kernel PPP (GPLv2; we reimplement the grammar/behavior, never vendor the code, so pdbd stays permissively licensed).Userspace IP — the road not taken. We terminate IP in the kernel via a TUN device (real sockets, kernel TCP reliability). The alternative — a userspace TCP/IP stack — is proven but heavier: gVisor netstack (Go) and smoltcp (Rust). Recorded as the explicit fork in the design, not an oversight.
Transport-agnostic remote exec / RPC — the L7 we need:
u-root's cpu (Go) — plan9-cpu-inspired remote exec that carries namespaces over a flexible transport; closest in spirit to the exec half.gokrazy/breakglass (Go) — inject a static binary into an otherwise-immutable appliance and get an interactive debug shell; the "break glass into a sealed image" use-case, which is exactly ours.eRPC / EmbeddedRPC (C/C++) — RPC explicitly decoupled from transport (serial, TCP, USB, RPMsg); the strongest prior art for one RPC surface over many byte pipes.citizenshell (Python) — one shell API over telnet / ssh / serial / adb; the clearest statement of the unification goal pdbd chases, from the scripting world.L1 address grammar:
websocat / socat — socat-style address specifiers; the dialect reference for pdbd's --socket L1 addresses (grammar reimplemented, never copied from GPL socat).Control-plane wire & mux standards — what the interop requirement draws on:
quinn) — the mux + connection-migration reference designs. QUIC-as-the-core-transport was weighed and set aside: it bundles mux + reliability + crypto that pdbd already gets from the kernel + PPP, and (like TLS) it is substrate-flexible, so it stays an external secured L1 (a QUIC tunnel / quicat via EXEC:), never a built-in. Its migration design is still the comparison point for our PPP recovery (below). WebTransport — QUIC wearing an OSI-bound web architecture — is built in; raw QUIC is not (see Crypto).yamux (libp2p) — a mature userspace stream multiplexer; the thing we don't pull, because the kernel IP layer makes it unnecessary.Multiplexing daemon & privilege separation — the process-model prior art:
adb — one binary is client and server, binds localhost:5037, auto-starts the server if absent, refcounts, and is "one giant multiplexing loop." The direct model for pdb central's singleton / auto-spawn / refcount lifecycle.Roaming / recovery from a dead peer:
Port-forward / tunnel fleet — the forward/bind prior art:
russh (Rust) — exposes direct-tcpip/forward-tcpip + unix-socket forwarding; the embeddable-SSH reference for the forwarding primitives.pdbd aims to be).The tools this unifies: adb, ssh, qemu-guest-agent, docker exec — each solves one transport or one capability; pdbd is the single endpoint that spans them.
Candidate Rust dependencies, by layer — versions verified against crates.io on 2026-10-04.
L2 — PPP:
ppproto 0.2.1 — sans-IO PPP state machine (LCP + IPCP). Primary L2 engine. HDLC framing + FCS are internal to it; a standalone hdlc 0.4.1 is the fallback only if we drive framing ourselves.L3 — TUN device:
tun-rs 2.8.11 — cross-platform TUN/TAP, async-capable; broadest device support. Preferred.tun 0.8.14 / tokio-tun 0.15.2 — leaner Linux-first alternatives if we don't need the portability surface.L1 — transports (pluggable):
tokio-serial 5.5.0 (async, over mio-serial 5.0.7 / serialport 4.10.1) — the v0 transport.tokio-vsock 0.7.2 — the VM-guest transport.tokio's UdpSocket with a datagram framing — a lossy datagram L1 (no extra crate); the archetypal pipe PPP + kernel-TCP is designed to recover over.ws/wss): tokio-tungstenite 0.30.0 (establishes ws:// and, with tokio-rustls 0.26.6, wss://) + ws_stream_tungstenite 0.15.0 (adapts the WebSocket to AsyncRead/AsyncWrite). wss is the primary built-in secure transport (see Crypto). Feature-gated.webtransport, built-in secure): wtransport 0.7.2 — WebTransport over HTTP/3 on quinn; built in because its web architecture is OSI-bound (see Crypto), a datagram session the default exposure. Needs UDP reachability. Feature-gated.Kernel plumbing:
rtnetlink 0.23.0 — program routes/addresses on the TUN from the IPCP-negotiated values, without shelling out to ip.Control plane — RPC (language-neutral; see Control plane):
tonic 0.14.6 — gRPC/HTTP-2; recommended for the three control segments. Its HTTP/2 stream mux is the control-plane multiplexer, and the wire is a CNCF standard with first-party implementations in every target language.capnp-rpc 0.27.0 — Cap'n Proto RPC; the alternative (promise pipelining, lighter than HTTP/2).Process model (daemon workers + exec):
nix 0.31.3 — fork/execve/waitpid and the raw syscalls the worker + exec model needs.shell): portable-pty 0.9.0 (wezterm, cross-platform, mature) or pty-process 0.5.3 (tokio-native).interprocess 2.4.4 — async cross-platform local IPC (UDS + named pipes) for the ephemeral↔central socket, if not reusing the RPC lib's own UDS transport.sendfd 0.4.5 / anchovy 0.4.1 (async) / command-fds 0.3.3 — SCM_RIGHTS fd-passing, for the optional same-host tunnel-handoff optimization only (not the portable default; see Tunnel ownership).Considered and rejected:
yamux 0.14.1 / tokio-yamux 0.3.20 — userspace stream multiplexer. Unnecessary: the kernel IP layer multiplexes the data plane by 4-tuple, and the RPC framework multiplexes the control plane. We never carry many logical streams over one connection ourselves.pdbd already gets from kernel + PPP, and it is substrate-flexible (prone to the same L3 "explosion" as TLS), so it stays external — a QUIC tunnel / quicat via EXEC: (see Crypto). quinn 0.11.12 still rides in transitively under wtransport for WebTransport.tarpc — Rust-/serde-private RPC; disqualified by the interop requirement (no language-neutral wire a gopdb/jpdb could target).v0 (the core): a daemon + client, userspace-PPP + TUN over a serial transport, the control channel, and exec + forward.
Later: kernel-PPP backend, udp/vsock/ws transports, the feature-gated built-in secure transports (wss, webtransport), multi-IP zones on the general-purpose PPP network, pluggable auth — and the broader ssh/adb/docker-unification arc.
Interoperability (a first-class goal, not an afterthought): a published, versioned pdbd.proto is the cross-language contract. Independent reimplementations — gopdbd/gopdb, jpdbd/jpdb, cpdbd/cpdb — are meant to be plug-and-play with this reference impl and each other, mixing freely across the three control segments (see Control plane). An alternative daemon may skip a multiprocess worker pool; if it keeps one, that boundary must speak the standard wire.
Per-deployment: environment specifics — e.g. the guest kernel's CONFIG_TUN, or whatever a mandatory-access-control policy on an enforcing host must grant the daemon so it can create its TUN and program netfilter — are a property of each use case, not of pdbd itself.
MIT — see LICENSE. The socat-style address grammar is reimplemented, never copied from GPL socat (an interface/grammar isn't copyrightable; the implementation is).