podnet is one Rust binary that does the work of three tools:
| Tool | podnet command |
|---|---|
cloudflared quick tunnels and named tunnels |
podnet tunnel, podnet serve, podnet share, podnet connect |
Try Cloudflare (*.trycloudflare.com) |
podnet tunnel with no account. A quick tunnel is for tests and development: no uptime guarantee, at most 200 requests in flight (more get 429), no Server-Sent Events, and the Cloudflare Online Services Terms (limits and terms) |
| The Cloudflare WARP client | podnet warp |
podnet is made for hosts with strong limits. The table gives each limit, the
part of podnet for it, and where that part is tested.
| Limit on the host | Part of podnet |
Tested |
|---|---|---|
| No UDP to the edge | HTTP/2 edge leg on TCP 7844 | CI, live against the Cloudflare edge |
| Only TCP 443 out | QUIC edge leg inside WARP (MASQUE) | CI, live |
| No DNS resolver | DNS over HTTPS by IP address (1.1.1.1, 8.8.8.8), then a static edge list | CI and a sandbox with an HTTP proxy |
| Egress through a proxy only | HTTP CONNECT, SOCKS5 and NO_PROXY for each connection |
tests/egress.rs |
| No root, no TUN, no network namespace | podnet pod (seccomp backend), podnet warp run |
CI and a sandbox |
No AF_INET bind |
podnet net (shim and userspace stack), unix-socket origins |
CI and a sandbox |
The live workflow (.github/workflows/live.yml, on each push and each day) runs podnet demo with
each edge leg on a GitHub runner and fetches the public URL. One limit stays:
a proxy that terminates TLS cannot carry the edge connection or the MASQUE
connection, because both need the Cloudflare certificate chain (and MASQUE
needs a client certificate).
The repository is a Cargo workspace:
| Crate | Contents |
|---|---|
podnet (root) |
The CLI and the library: the edge client (provisioning, discovery, QUIC, HTTP/2, Cap'n Proto and its RPC), the service layer, WARP, the virtual network. |
crates/podnet-netns |
A library: run a program with all its traffic sent through a proxy (podnet pod). |
Each release has static
Linux binaries of podnet for x86_64, aarch64, armv7,
riscv64gc, i686, arm (ARMv6, hard float), loongarch64 and
powerpc64le (from v0.3.0-beta.2 on; v0.3.0-beta.1 has x86_64,
aarch64, armv7 and riscv64gc, and no manual page). podnet is for Linux
only. This README describes main; the notes in docs/releases/ say what
a tag has. Each release also has these files:
| File | Contents |
|---|---|
SHA256SUMS |
The SHA-256 of each binary and of podnet.1. |
SHA256SUMS.sigstore.json |
The keyless cosign signature of SHA256SUMS. |
provenance.sigstore.json |
The SLSA provenance of the binaries. |
podnet.1 |
The manual page (podnet man --roff). |
THIRD-PARTY-LICENSES.md |
The license texts of the crates in the binaries. |
Verify the files before you use them. Download the binary, SHA256SUMS and
the two bundles to one directory. Then do these steps:
-
Check the checksums. The command ignores the files that you did not download:
sha256sum -c --ignore-missing SHA256SUMS
-
Check the signature of
SHA256SUMS. ReplacevX.Y.Zwith the tag:cosign verify-blob --bundle SHA256SUMS.sigstore.json \ --certificate-identity https://github.com/PodUtils/podnet/.github/workflows/release.yml@refs/tags/vX.Y.Z \ --certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
-
Check the provenance of the binary (gh 2.49 or later):
gh attestation verify podnet-x86_64-unknown-linux-musl --repo PodUtils/podnet \ --signer-workflow PodUtils/podnet/.github/workflows/release.yml
With no network access to the GitHub API, give the bundle and a trusted root. Make
trusted_root.jsonlon a host with network access (gh attestation trusted-root > trusted_root.jsonl), and copy it:gh attestation verify podnet-x86_64-unknown-linux-musl --repo PodUtils/podnet \ --signer-workflow PodUtils/podnet/.github/workflows/release.yml \ --bundle provenance.sigstore.json --custom-trusted-root trusted_root.jsonl
The releases up to v0.3.0-beta.12 have the old name cfrs. Their
binaries are cfrs-<target> and pod-netns-<target>, their manual page is
cfrs.1, and their image is ghcr.io/azathothas/cfrs:<tag>. The identity
of their signature is
https://github.com/Azathothas/cfrs/.github/workflows/release.yml@refs/tags/<tag>.
For these releases, use these names and this identity in the steps above.
A binary that has one changed byte fails each check. The workflow
.github/workflows/release-proofs.yml does these checks on a fresh runner
after each release. It also does each check on a changed copy, which must
fail.
scripts/install.sh installs the binary of the newest release
(prereleases too) for this machine. It is POSIX sh:
s=$(curl -fsSL https://raw.githubusercontent.com/PodUtils/podnet/main/scripts/install.sh) && sh -c "$s"With wget: s=$(wget -qO- URL) && sh -c "$s". Do not use curl URL | sh
in a script or a CI step: a pipe has the exit status of sh, and sh
exits 0 when curl fails or is missing.
- It uses the first downloader that it finds:
curl,wget,aria2c,http(HTTPie),python3,pythonorperl(HTTP::Tinywith TLS), and lastbusybox wget(also awgetthat is the busybox applet: it goes afterpython3,pythonandperl). HTTPS only; redirects to plain HTTP are refused. busybox wget does not check TLS certificates: the script says so in a warning, and with nocosignthe check ofSHA256SUMSthen does not protect the download. A busybox with no TLS (nossl_client) is not used. - It checks the binary against
SHA256SUMS(sha256sum,shasum,openssl,busyboxorpython3), and the cosign signature ofSHA256SUMSwhencosignis installed. A failed check stops it, and cosign then shows its output. - It runs the new binary (
--version) in the install directory before it replaces the old binary. A binary that does not run on this machine, or that does not printpodnet VERSIONof the release, does not replace the old one. The copy is a new file frommktempwith mode 0755 (also with a restrictive umask). When the path of the binary is a directory or another file that is not a regular file, the script stops before the downloads. - The directory:
PODNET_INSTALL_DIR, else/usr/local/binfor root, else$XDG_BIN_HOME, else~/.local/bin. A line says how to add it toPATH. The script checks the directory before the downloads. PODNET_VERSION=<TAG>(for examplev0.3.0-beta.11) installs that release, with no request to the GitHub API. For the newest release, the script reads the API; when the API refuses (403 on a shared address), it readsreleases.atom. A draft release is not used.GITHUB_TOKENorGH_TOKENgoes toapi.github.comonly. The token is not in the trace ofDEBUG=1and not in the arguments of the downloader: curl reads it from stdin, wget from a private file.PODNET_DOWNLOADER=wgetselects the downloader.- A missing file of the release, an empty download and a failed download have different messages. INT, TERM and HUP stop the script and remove its files.
A hosted notebook, a rebuilt container or a CI job can lose its disk at each start. Then three things go:
- The binary: give
PODNET_INSTALL_DIRa directory that stays. - The WARP device: give
PODNET_STATE_DIR(or--identity PATH) a directory that stays. Without it, each start registers a new anonymous device (the service limits new devices; see "WARP").PODNET_STATE_DIR=noneuses a new device on purpose. - The release list: a pinned
PODNET_VERSIONuses no GitHub API.
This cell (Python 3, no curl) installs podnet in KEEP. The same cell is in
notebooks/install.ipynb. It fails when the install fails (check=True):
# Install podnet on a host that loses its disk at each start (a notebook).
# KEEP is a directory that stays, for example a mounted drive.
import os
import subprocess
import urllib.request
KEEP = os.environ.get("PODNET_KEEP", os.path.expanduser("~/podnet-keep"))
VERSION = "latest" # A tag (for example v0.3.0-beta.11) uses no GitHub API.
URL = "https://raw.githubusercontent.com/PodUtils/podnet/main/scripts/install.sh"
os.makedirs(os.path.join(KEEP, "bin"), exist_ok=True)
script = os.path.join(KEEP, "install.sh")
with open(script, "wb") as f:
f.write(urllib.request.urlopen(URL, timeout=60).read())
env = dict(os.environ, PODNET_INSTALL_DIR=os.path.join(KEEP, "bin"), PODNET_VERSION=VERSION)
subprocess.run(["sh", script], env=env, check=True)
# The binary and the WARP device stay in KEEP.
os.environ["PATH"] = os.path.join(KEEP, "bin") + ":" + os.environ["PATH"]
os.environ["PODNET_STATE_DIR"] = os.path.join(KEEP, "state")
print(subprocess.run(["podnet", "--version"], check=True, capture_output=True, text=True).stdout)Each release has an image for each Linux architecture of the binaries:
ghcr.io/podutils/podnet:<tag> (a manifest list), :<tag>-<arch> for one
architecture (amd64, arm64, armv7, armv6, 386, riscv64,
ppc64le, loong64), and :beta for the newest prerelease. :latest is
for the newest release that is not a prerelease. No such release exists
now, so :latest has no image. The image is FROM scratch with podnet
in /usr/local/bin. It runs as user 65534. The state
directory is the volume /var/lib/podnet (PODNET_STATE_DIR). The entry point
is podnet. In the container, podnet is PID 1 (no init): SIGTERM and SIGINT
stop it at each stage, also before the ready line (exit 143 and 130).
docker run --rm ghcr.io/podutils/podnet:beta --version
docker run -d --name warp -v podnet-state:/var/lib/podnet -p 127.0.0.1:40000:40000 \
ghcr.io/podutils/podnet:beta warp proxy --listen tcp://0.0.0.0:40000
docker run --rm ghcr.io/podutils/podnet:beta demo --exit-on-verifyThe proxy listens on all addresses of the container: publish the port on
127.0.0.1 only, or give --proxy-user and --proxy-password-file.
# Make a quick tunnel to a local HTTP server.
podnet tunnel --url http://127.0.0.1:8080
# Make a quick tunnel to an HTTP server on a unix socket.
podnet tunnel --unix /run/app.sock
# Test the full path: a built-in origin, a tunnel, a fetch of the public URL.
podnet demo --exit-on-verifypodnet update --check # say whether a newer release exists
podnet update # install it
podnet update --version <TAG> # install the release <TAG>
podnet update --rollback # put the binary before the update backpodnet update installs a release only after these checks: the certificate
of SHA256SUMS.sigstore.json chains to the Fulcio roots of Sigstore and
names release.yml of this repository at the tag, the signature over
SHA256SUMS is valid, the SHA-256 of the binary is its line in
SHA256SUMS, and the new binary runs --version in 10 seconds, with no
stdin, exits 0 and prints the version of the tag. After the run, the file
must still be the same file (device and inode), a regular file of this
user that other users cannot write; else podnet stops. Then the binary before
is kept as podnet.prev beside it, and a rename puts the new binary (mode
0755, also with a restrictive umask) in place. podnet update --rollback puts podnet.prev back (it must pass the
same run first) and keeps the binary that was in place as podnet.prev;
it uses no network. podnet does not check the inclusion proof of the Rekor
log. A prerelease is
installed only with --version, or when the current binary is a
prerelease.
--version TAGuses no request to the GitHub API: the URLs of the files follow from the tag. A tag with no release exits 69 ("no release has the tag"). A text that is not a tag (a/, a.., a character other than0-9 A-Z a-z . + -) exits 64. podnet skips such a tag in the API and inreleases.atomtoo.- With no
--version, podnet reads the GitHub API, elsereleases.atom(the API answers 403 on a shared address; the feed has no quota). --versionof the installed version says so and exits 0;--forceinstalls it again.--forcewith no--versioninstalls the newest release also when it is the installed version; it does not go back to an older release.- The repository is fixed (it names the signer of the binary). podnet does
not read
PODNET_REPO(a variable ofscripts/install.sh); a warning says so.
podnet tunnel --url http://127.0.0.1:8080 # HTTP or HTTPS origin
podnet tunnel --port 8080 # the same as http://127.0.0.1:8080
podnet tunnel --unix /run/app.sock # HTTP origin on a unix socket
podnet tunnel --hello-world # built-in test origin
podnet tunnel --config ./config.yml # cloudflared ingress rules
podnet tunnel --token "$T" --tcp 10.0.0.5:5432 # raw TCP origin (named tunnel)A raw TCP origin (--tcp host:port or tcp://host:port) carries TCP and
WebSocket streams of a named tunnel (WARP routing, cloudflared access tcp). A quick tunnel receives HTTP: podnet answers each HTTP request to a
raw TCP origin with 501, and does not verify the public URL. For TCP
through a quick tunnel, use --forward and podnet connect (see "Raw
streams through a quick tunnel").
The service strings are the same as in cloudflared:
http://host:port https://host:port unix:/path/to.sock
unix+tls:/path.sock tcp://host:port http_status:404
hello_world metrics static:/srv/www
spa:/srv/www
static:serves a directory.spa:serves a directory and sendsindex.htmlfor unknown paths.metricsserves the Prometheus metrics of the tunnel.podnet_decisions_total{decision,outcome}counts the path decisions of the process:dns(ip,pin,system,doh,fail),egress(direct,none,no_proxy,via,http,socks5,unix,exec),leg(the leg that won),masque(direct,relay,relay_first,no_relay),dispatch(the requests ofwarp proxy) andrule. The health line ofwarp proxyhas the same counters.--http-host-header NAMEsets theHostheader for the origin.--no-tls-verifydoes not verify the TLS certificate of an HTTPS origin.--no-verify(tunnel,serve,share,demo) does not fetch the public URL after the registration. The fetch proves that the URL answers. The flag does not change the check of an origin certificate:--no-tls-verifydoes that. Use--no-verifyon a host that resolves a new*.trycloudflare.comname late.
All built-in origins run in the process. They do not open a TCP listener.
podnet tunnel --config reads a cloudflared YAML file:
tunnel: my-tunnel # the name or the UUID of the tunnel
credentials-file: /etc/podnet/creds.json
ha-connections: 2
protocol: auto
ingress:
- hostname: app.example.com
path: /api
service: http://127.0.0.1:8080
originRequest:
noTLSVerify: true
connectTimeout: 5s
- service: http_status:404- The last rule must match all requests (no
hostname, nopath). podnet refuses a file where it does not. path:is a regular expression, as in cloudflared (the syntax of the Rustregexcrate, which is close to Go RE2). It matches when it matches a part of the request path:path: /apialso matches/x/apiand/apix. Writepath: ^/api/for the paths that start with/api/, andpath: ^/api$for one path. A.matches each character: write\.for a dot. The query (from?) is not part of the path. The match reads the decoded path, as cloudflared does:/a%20bmatches^/a b$(a+stays a+). Apath:that is not a valid regular expression is an error at load (exit 78), with the file and the rule number.- With no
ingress:,url:gives one rule for each request (the same services asservice:), andhello-world: truegives the hello-world page.url:orhello-world:withingress:is an error. - A file with no rule (no
ingress:,url:orhello-world:) fails--check(exit 78) and the run. - With
credentials-file:,tunnel:is the name or the UUID of the tunnel, as in cloudflared. A UUID must be theTunnelIDof the file. Withtunnel:only, it is a tunnel token. token:is the tunnel token of a named tunnel.token:withcredentials-file:is an error. Withtoken:, atunnel:must be the tunnel id of the token.- With no
tunnel:, nocredentials-file:and notoken:, podnet starts a quick tunnel.--token,TUNNEL_TOKENand--credentials-filewin over the file. - podnet ignores the cloudflared keys that it does not use (
metrics,loglevel,warp-routing,tcpKeepAlive, ...). Another key is an error (exit 78), with the nearest key. podnet checks the keys at the top level, in each ingress rule and in eachoriginRequest. - The top-level
originRequestis the default of each rule. A key of the rule wins. protocol:andha-connections:of the file are used. A flag (--protocol,--ha-connections) wins over the file.--tokenand--credentials-fileon one command line are a usage error (exit 64). With the token fromTUNNEL_TOKEN,--credentials-filewins, and a warning says so. A bad token or credentials file exits 78.- A UTF-8 BOM at the start of the file is skipped. A list, an empty file, a
rule with no
service, a time with no unit (connectTimeout: 5) and a value of the wrong type each have their own message.
podnet reads the YAML with its own reader (src/yaml.rs). The reader accepts
block mappings, block sequences, plain and quoted scalars on one line, and
comments. It refuses anchors, aliases, tags, block scalars (|, >), flow
collections that are not empty, tabs in the indentation, duplicate keys and
a document that nests deeper than 64 levels, and gives the line number.
A named tunnel uses a Cloudflare account and a hostname from the dashboard.
TUNNEL_TOKEN=<token> podnet tunnel --url http://127.0.0.1:8080
podnet tunnel --token-file /run/secrets/tunnel-token --url http://127.0.0.1:8080
podnet tunnel --credentials-file ~/.cloudflared/<id>.json --url http://127.0.0.1:8080--token <token> also works. Other users of the host can read a command
line, so TUNNEL_TOKEN or a file is safer. --token-file PATH (or
TUNNEL_TOKEN_FILE) reads the token from a file; white space at the ends
is removed. podnet relay connect --token-file PATH reads a connect token,
and podnet warp license - reads the license key from stdin.
A command line of cloudflared works for a tunnel: podnet tunnel run --token T is podnet tunnel --token T, --no-autoupdate is ignored, and
--loglevel debug|info|warn|error becomes -v, nothing or -q. Another
flag of cloudflared (--logfile, --pidfile, --metrics, ...) is a usage
error with the step to take in podnet.
--protocol selects the protocol to the edge. --transport selects the
path.
| Leg | Socket on the host | Use it when |
|---|---|---|
quic |
UDP 7844 | UDP to the edge is open. This is the fastest leg. |
http2 |
TCP 7844, directly or through the proxy | UDP is blocked and TCP 7844 is open. |
quic+warp |
TCP 443 only (MASQUE) | Only TCP 443 is open. |
http2+relay |
TCP 443 to a WebSocket relay | Only TCP 443 is open, and WARP does not work (the proxy terminates TLS or takes names only). Only with --transport relay. |
With the default values (--protocol auto --transport auto), podnet tries the
legs in the order of the table, in a staggered race: the next leg starts when
the leg before has not registered in 2 seconds, or at once when it fails, and
the first leg that registers stays (docs/protocol.md section 3.3).
--leg-failover serial waits for each failure. The time limit of an attempt
is the budget of its leg: 130 seconds for quic, 280 seconds for http2
and http2+relay (docs/protocol.md section 3.3). Other values:
--protocol |
--transport |
Legs |
|---|---|---|
auto |
auto |
quic, http2, quic+warp |
quic |
auto |
quic, quic+warp |
http2 |
auto or direct |
http2 |
auto |
direct |
quic, http2 |
quic |
direct |
quic |
auto or quic |
warp |
quic+warp |
auto or http2 |
relay |
http2+relay |
h2 is a name of http2. With a proxy, auto drops quic (see "Proxies
and DNS"); an explicit --protocol quic keeps it, and a warning says that
QUIC goes around the proxy.
--transport relay sends the HTTP/2 leg in a WebSocket to a
WebSocket-to-TCP relay on TCP 443 (--relay URL, or PODNET_RELAY; the
default is https://tcp.ssh.relay.ajam.dev). The relay opens TCP to the
edge on port 7844. The TLS to the edge is in the WebSocket, so the relay
does not see the tunnel. PODNET_RELAY_TOKEN gives a relay token; without
it, podnet mints one and keeps it in the state directory. When the relay
host does not answer, podnet tries other hosts of the same deployment (a
comma list in --relay, else the pool of the relay). The relay carries IPv4 TCP only, and it ends a
session after 64 MiB or 12 hours. Before that (at 60 MiB or 11.5 hours),
the tunnel connects a replacement edge connection, and the old one
finishes its open streams (docs/protocol.md section 3.4). A WARP
(MASQUE) session through the relay is replaced at the same limits: a new
session takes the new connections, and the open connections go on in the
old session for 90 s.
--warp-identity PATH keeps the WARP device in a file. Without it, each run
registers a new anonymous device. PODNET_EDGE=ip:port,... replaces edge
discovery.
podnet serve --dir ./public
podnet serve --dir ./app --spa # send index.html for unknown paths
podnet serve --dir ./public --local 127.0.0.1:8080 # this host only, no tunnel
podnet serve --dir ./public --local unix:/run/site.sock # a unix socket, no tunnelWith no --local, podnet serve makes a public quick tunnel (or the named
tunnel of --token). --local ADDR serves the same files on a local
listener and makes no tunnel:
ADDRisHOST:PORT,tcp://HOST:PORTorunix:PATH.- Port 0 takes a free port. podnet prints the bound address
(
podnet: serve DIR on http://127.0.0.1:PORT/). With--json, thereadyline hasurlandlisten. - A unix socket has mode 0600. A socket that answers is refused (exit
69;
--replacetakes it over). A stale socket is removed. A file that is not a socket is refused (exit 64). podnet removes its socket at the end. - An address in use is refused (exit 69).
--auth-userand--checkare refused with--local. The other tunnel flags have no effect.
podnet serve serves only the files in --dir:
- A symlink is served only when its real path is in
--dir. - A name that starts with a dot (
.git,.env) is not served. - Only regular files and directories are served (no FIFO, socket or device).
Each other path answers 404. With --spa, an unknown path answers the
index.html with status 200.
The library type podnet::origin::StaticDir has the same rules. It also
applies them to the directory index and to the SPA index.
podnet::feature::StaticServer::respond gives a StaticResponse. Its
body is a reader (std::io::Read) that reads the file when you read
it, and write_to writes the head and the body. A file of any size is
served with a bounded memory.
podnet share --mode upload --password # receive files, with a PIN
podnet share --mode download --file ./report.pdf # send one file
podnet share --mode proxy --port 3000 # share a local port, with a PIN
podnet share --mode proxy --unix /run/api.sock # share an HTTP unix socket, with a PIN
podnet share --mode upload --dir /tmp/inbox --stop-after --idle-secs 600- Give a mode:
--mode upload(or--dir DIR),--file FILE,--port PORTor--unix PATH. A barepodnet shareis a usage error (exit 64), and opens no session. With--mode uploadand no--dir, the files go to~/Downloads/podnet(or~/podnet-uploadswhen~/Downloadsdoes not exist). - The public path is
/s/<token>/. The token has 32 random bytes, and a path with another token answers 404. --passwordadds a 6-digit PIN. The host shows the PIN. The PIN check uses a constant-time compare. After 10 wrong codes, no code opens the session: start a new one. Requests at the same time get at most 10 compares in total. A correct code sets the count of wrong codes to 0. After the unlock, the browser goes back to a path of the same site only.- The unlock cookie has
HttpOnly,SecureandSameSite=Lax: open the session withhttps://(a browser does not keep aSecurecookie of plain http). An unlock POST from another site (Sec-Fetch-Site, or anOriginthat is not the host) gets 403 and uses no try. A client that knows the URL can still use the 10 tries: the edge does not give podnet the address of the visitor, so the limit is for the session, not for each visitor. - A locked GET or HEAD gets the PIN page with 200 (for a browser). A locked request with another method (an upload POST, an API call) gets the page with 403, and nothing is done. A wrong PIN gets 403.
- The upload limits are 512 MiB for each file, 32 files and 4 GiB in total
for each session. A request over a limit gets 413, and no file of that
request is saved: each file waits in a hidden
.partfile until the request ends. A body with no data for 60 s gets 408 and leaves no file. - An uploaded file has mode 0600 (only the user of podnet can read it), with each umask.
- One upload request holds at most
--max-requestbytes (default 100000000, the limit of the Cloudflare edge for one request body). Each field of the request counts, also a field with no file. A largerContent-Lengthgets 413 and saves nothing: podnet reads the body and drops it first (until its end, a pause of 5 s, or 120 s), because an answer before the end of the upload does not reach the visitor reliably through the edge. The page refuses such a selection before it sends. To send more, use more uploads. - A download name that is not ASCII goes in
filename*=UTF-8''...(RFC 5987), with an ASCIIfilename=. --stop-afterstops the session after the first transfer.--idle-secsstops it after a time with no activity. The idle clock starts when the session serves, not before the provisioning.- Each reply of a share session has
Cache-Control: no-store. In proxy mode, the replies of the local server keep their own headers. - A download answers
If-None-Match: *and a weak tag (W/"...") with 304.
umask 077; printf '%s\n' 'a long password' > ~/.podnet-pass
podnet tunnel --port 3000 --auth-user me --auth-password-file ~/.podnet-pass
curl -u me:'a long password' https://<random>.trycloudflare.com/--auth-user USERand--auth-password-file FILE(PODNET_AUTH_USER,PODNET_AUTH_PASSWORD_FILE) work withtunnel,serve,shareanddemo. Each request must give the user and the password (HTTP Basic). Else podnet answers 401 withWWW-Authenticate: Basic realm="podnet", and the request reaches no origin. The origin does not get theAuthorizationheader.- The file has the password on its first line and mode 0600; else exit 78. The password is not on the command line.
- The check covers the HTTP origins and the
--forwardendpoint:podnet connect --auth-user USER --auth-password-file FILEsends the credential in the WebSocket upgrade. A raw--tcporigin carries no HTTP header:--auth-userwith--tcpis a usage error (exit 64). - The verify of the public URL takes the 401 as the normal answer.
- Basic goes in the TLS to the edge of Cloudflare: Cloudflare can see it. The credential does not hide the URL.
A quick tunnel carries HTTP and WebSocket. --forward puts a WebSocket
endpoint at /__podnet/ws. podnet connect changes local sockets into
connections through that endpoint. Thus SSH, databases and unix sockets can
go through a quick tunnel.
# Server: expose a unix socket (or tcp://host:port).
podnet tunnel --forward unix:/run/app.sock
# Client: local port 8080 goes to that unix socket.
podnet connect wss://<random>.trycloudflare.com/__podnet/ws -L tcp://127.0.0.1:8080The tunnel prints the endpoint (podnet: forward wss://...). The ready
event of --json, the ready document of --detach and podnet ps --json
have it as forward (and forward_path); podnet ps shows it on a second
line.
-L accepts tcp://[bind:]port and unix:///path and is repeatable. Each
local connection gets its own WebSocket. --forward and an origin can be used
together: the origin gets all paths other than /__podnet/ws.
podnet connect dials as the other commands do: --proxy (or HTTPS_PROXY and
NO_PROXY), --resolve, the system resolver and DoH, and the roots of
--ca-file. At start, it opens one WebSocket to the remote and exits 69 when
the remote does not answer; --no-check starts without this check. A unix
socket that another process serves is refused (exit 69); --replace takes
it. At the end, podnet removes the socket files that it made. --detach and
--name work as for tunnel (see "Run in the background"); the ready
document has remote, listen and listeners. At -v, each WebSocket
logs the time of its TCP, TLS and upgrade steps.
podnet ports # list the listening TCP ports (Linux)
podnet ports --probe # only the ports that accept a connection
podnet qr <URL> # print a QR codepodnet warp is a WARP client. It uses MASQUE (CONNECT-IP over HTTP/2) on
TCP 443. It needs no root and no TUN device.
podnet warp register # register a device, or load the saved one
podnet warp status # the device and the account
podnet warp status --json # the account record as JSON
podnet warp license <KEY> # attach a WARP+ license key
podnet warp rotate # make and enrol a new MASQUE key
podnet warp delete # remove the device and the saved file
podnet warp probe # test TCP connects through the tunnel
podnet warp exit # the exit colo, country, address and IPv6 address
podnet warp scan --candidate 162.159.198.1 # measure front doors (TCP and TLS)
podnet warp export --out warp.conf # a WireGuard device for other clients (UDP)
podnet warp proxy # local SOCKS5 and HTTP CONNECT proxy
podnet warp forward -L 5432=db.internal:5432 # as ssh -L, through WARP
podnet warp forward --list # the forwards of the detached instances
podnet warp run -- curl https://cloudflare.com/cdn-cgi/trace
podnet warp tun --interface podnet0 # TUN interface (needs CAP_NET_ADMIN)-
The device and its key are in
warp.jsonin the state directory: the first that podnet can write ofPODNET_STATE_DIR,$XDG_STATE_HOME/podnet,~/.local/state/podnet,$XDG_RUNTIME_DIR/podnetandpodnet-<uid>in the temporary directory. The file mode is 0600. podnet checks the directory before it registers a device. podnet writes no file in a directory of another user (root with theHOMEof a user): the state directory skips thatHOME, andwarp ssh-config --write,warp up --persistand--env-d, andautostartexit 78.PODNET_FOREIGN_HOME=allowwrites anyway. Use--identity PATHfor a different file,--ephemeralorPODNET_STATE_DIR=noneto use a new device for one run. -
IPv6: the stack has the IPv4 and the IPv6 address of the device. The proxy asks for A and AAAA records through the tunnel and tries IPv4 first, then IPv6, so a host with only AAAA records works too.
warp tunadds the IPv6 address (/128) to the interface. -
What a destination sees (measured by a tester on 0.3.0-beta.8; issue 183): not the address of the device (
172.16.0.2,2606:4700:110:...), but an egress address of Cloudflare. The egress address stays the same in one MASQUE session, and a new session gets a new one (IPv6 from a2a09:bac1::/32or2a09:bac5::/32prefix). The--listenlisteners of one proxy share one session and one address; each--listen-identityhas its own device, session and address.warp rotatechanges the MASQUE key only.cdn-cgi/tracegives the IPv4ip=also for an IPv6 flow. -
--session-max-age SECS(orPODNET_WARP_SESSION_MAX_AGE) closes each session at that age and opens a new one, with a new egress address, forwarp proxy,forward,run,upandtun. The TCP flows of the old session end. Default: off. The log gives the cause of each close of a session. -
--probe IP:PORT(default1.1.1.1:443) is the TCP probe of the data path. A session can carry ICMP and UDP but no TCP. Each 30 s podnet connects to the target through the session (warp tun: one SYN from the tunnel address and port 61999; the answer does not go to the TUN). After 3 failures in a row, a new session takes the new flows (the log namesreason=tcp-probe).--probe offstops it. -
New devices: the WARP service limits the registrations of each address. Observed: HTTP 429 (error 1015) after about 10 new devices in 90 minutes, cleared after about 30 minutes. podnet then says so. A saved identity,
warp rotateand new sessions do not register.--ephemeral, a new--listen-identityfile andwarp exporteach register one device. -
podnet warp proxylistens ontcp://127.0.0.1:40000by default. Use--listen unix:/pathon a host that refusesAF_INETbinds. Each--listenis bound on its own: a listener that the host refuses is reported, and the proxy starts with the others (--strictexits 69 instead). With no listener, the exit code is 69. Names are resolved through the tunnel. A pin of--resolve HOST=IPorPODNET_RESOLVEcomes first and sends no query. This applies also towarp forwardandwarp nc. SOCKS5 UDP ASSOCIATE is supported. -
--proxy-user USER --proxy-password-file FILE(also onwarp up): each client must give the user and the password of the first line of FILE (mode 0600), with SOCKS5 (RFC 1929) or HTTPProxy-Authorization: Basic. A listener on an address that is not loopback, with no credentials, gets a warning.--max-connections Nlimits the clients;--dns ADDRanswers DNS through WARP. -
--fragment-client-hello [off|record|sni](defaultoff; a flag ofpodnet warp, before or after the subcommand;PODNET_WARP_FRAGMENT): the TLS ClientHello of the MASQUE connection goes in more than one record of--fragment-sizebytes (16 to 4096; default 64-160).sniends the first record in the server name. The server joins the records (RFC 8446, section 5.1); a middlebox that reads the name from the first record does not find it. -
--carry wg(a flag ofpodnet warp, before or after the subcommand;PODNET_WARP_CARRY): a second WireGuard device (warp-carry.jsonin the state directory) inside the tunnel, on a UDP socket of the first stack. The tools use the stack of the second device (IPv4, MTU 1200), so a destination sees its exit. With--transport wireguard, both hops are WireGuard. Not forwarp tun. -
warp proxy --l4(PODNET_WARP_L4): each TCP client is one HTTP/3CONNECTstream to the L4 door of WARP (UDP 443, TLS name of--l4-sni,consumer-masque-proxy.cloudflareclient.com), with no IP stack in between. UDP (SOCKS5 UDP ASSOCIATE) is not served. The rules apply (-r,--rules-file,--rule-default): acidr:rule decides an address, and a name with no other rule takes--rule-default. -
--transport wireguard(a flag ofpodnet warp, before or after the subcommand;PODNET_WARP_TRANSPORT): the tunnel on WireGuard (UDP, port 2408 of the record, or--wireguard-endpoint) in place of MASQUE. The device is the one ofpodnet warp export(warp-wg.jsonin the state directory); podnet registers it when the file has none. The stack, the proxy and the commands are the same. There is no relay and no proxy: WireGuard is UDP. -
The reserved bytes: WARP reads bytes 1 to 3 of each WireGuard message, the
client_idof the device (# Reserved = a,b,cin the output ofwarp export,reservedin the JSON form). Kernel WireGuard and wg-quick send zeros there.--transport wireguardsends the bytes. A device that needs them has fresh handshakes and no data path in kernel WireGuard.podnet warp export --checkruns the WireGuard leg of podnet with the bytes and with zeros, with a TCP connect to the target of--probethrough each. It printsdevice ok(exit 0),needs reserved bytes(exit 1) ordevice data path dead, use --new(exit 69). The TCP probe of--probealso runs on the stack of--transport wireguard: a leg with fresh handshakes and no TCP gets a new session. -
--masque-transport h3(a flag ofpodnet warp, before or after the subcommand;PODNET_WARP_MASQUE_TRANSPORT): the MASQUE session over HTTP/3 on UDP 443 in place of HTTP/2 on TCP 443. Each IP packet is an HTTP datagram (RFC 9297), so a lost packet does not stop the other flows. A packet larger than a datagram goes as a capsule on the request stream. There is no relay and no proxy: they carry TCP only. -
--cc none|reno|cubic(a flag ofpodnet warp, before or after the subcommand;PODNET_WARP_CC): the congestion controller of the TCP connections of the userspace stack, for the data that it sends (an upload). The default isnone: only the window of the peer limits it. -
--listen-identity LISTEN=PATH(repeatable): the clients of LISTEN use the WARP device of the identity file PATH (podnet makes it when it does not exist), with a session of its own and its own address. The--listenlisteners use--identity. One file is for one listener only. The ready document lists the listeners and their devices inidentities. -
Rules of
warp proxy(-r KIND:VALUE=ACTION, repeatable): KIND isdomain(NAME, or.NAMEfor the name and the names under it) orcidr(ADDR/PREFIX, IPv4 or IPv6, or@FILEwith one prefix on each line, for example the prefixes of a country). ACTION istunnel,direct(from the host, with the resolver of the host and no proxy) orreject(SOCKS5 reply 0x02, HTTP 403). The first rule that matches wins. A name that no domain rule matches is resolved through the tunnel when a cidr rule exists. Then a loopback, a link-local or an unspecified address andlocalhostare refused, then--rule-default(tunnel) applies.--rules-file FILEadds the rules of FILE (one on each line,#starts a comment); podnet reads it again when it changes and keeps the rules before when it has an error.--checkprints the rules and exits. The rules apply to TCP; UDP goes through the tunnel. The health line counts the decisions.podnet warp proxy -r domain:.corp.example=direct -r cidr:10.0.0.0/8=direct \ -r domain:.ads.example=reject --rules-file ~/.config/podnet/rules -
podnet warp run --backend netns-rawruns the program in a user and network namespace whose TUN goes to WARP as raw IP packets: TCP, UDP, ICMP and IPv6, with the kernel stack of the namespace and no root. The TUN has the WARP addresses and the default routes, and the program gets aresolv.confwith 1.1.1.1.--backend autotakes it when this host can make the namespace (and no-rrule is given), else the pod below. -
podnet warp runstarts the proxy on a unix socket and runs the program withpodnet pod(autotakes theseccompbackend first, for UDP). It passes-r(for example-r cidr:10.0.0.0/8=direct),--keep-proxy-envand--inbound-dirto the pod. A connect to 127.0.0.1 goes to the host, not to WARP. The TCP connections and the DNS queries of the program go through WARP, also for static binaries. With theseccompbackend, other UDP datagrams also go through WARP (SOCKS5 UDP ASSOCIATE). ICMP (ping) goes through WARP only with--backend netns-raw; the pod does not carry it. -
podnet warp up [--port 40000] [--persist]startswarp proxyin the background (instancewarp, see "Run in the background") on127.0.0.1:PORTand on a unix socket, and writes an env file with the proxy variables. When the host refuses the TCP listener,warp upstarts with the socket only: the env file has no proxy variables,warp envsays so and exits 69 with no output, andwarp execandwarp shellwork. Later shells join it:eval "$(podnet warp env)":ALL_PROXY,HTTPS_PROXY,HTTP_PROXY,NO_PROXY(and the lower-case forms),CARGO_HTTP_PROXYand the npm variables. The proxy also forwardshttp://URLs (for apt).podnet warp exec -- CMDandpodnet warp shell: the command runs under the pod of the socket: TCP, DNS and UDP go through WARP, also for static binaries, with no proxy variable.--backend auto|netns|seccompselects the backend of the pod (defaultauto: seccomp first when it works). The pod writes one line on stderr that names the backend that runs and its limits;-qremoves the line. With-v, the pod logs each flow, and the seccomp backend logs each socket and eachsetsockoptthat it emulates.--persist: a marked block in~/.bashrcand~/.profileloads the env file in each new shell, and an interactive shell runs under the pod.--env-d:$XDG_CONFIG_HOME/environment.d/90-podnet-warp.conf(~/.config/environment.d/...when the variable is not set) has the proxy variables. The systemd user manager gives them to the programs of the next login (a desktop, a browser, a user unit). A file of that name that podnet did not write stays, andwarp up --env-dstops with an error.podnet warp downstops the instance and removes the env file and the blocks (and the environment.d file of podnet); the rest of each file stays.- The CA bundle: when
SSL_CERT_FILE,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLEorGIT_SSL_CAINFOnames a bundle of fewer than 10 certificates (the roots of a proxy that terminates TLS), the tools that use it fail through WARP, which shows the real chains.warp upthen writeswarp-ca.pem(that bundle and the system bundle) and the env file,warp execandwarp shellname it, so that the proxy and WARP both work. With no system bundle, a warning says to unset the variable.
-
podnet warp nc HOST PORT: a byte pipe from stdin and stdout to HOST:PORT through the runningpodnet warp up(else a WARP session of its own), forssh -o ProxyCommand='podnet warp nc %h %p'and git over SSH where port 22 is closed.--via-relaygoes through the WebSocket relay to HOST:PORT with no WARP.podnet warp ssh-config [--host PATTERN]prints the SSH config block and aGIT_SSH_COMMAND. SSH checks the host key as on a direct connection. At the end of stdin, podnet sends no FIN: the pipe ends when the far end closes, or--idleseconds (default 5) after the last data.-Nsends the FIN at the end of stdin, for a server that waits for it. An error of the far end exits 69. -
podnet warp ssh-config --write --host github.computs the block in~/.ssh/config(or--config FILE), between two marker lines, before the firstHostorMatchline. A second--writereplaces it (for example after podnet moved).--checkexits 1 when the block is missing or stale;--remove [--host PATTERN]removes it. No other line of the file changes. AHostline of the file with the same pattern is an error, and the files ofIncludeare not read.podnet warp downkeeps the block.--hostis one pattern of 1 to 255 characters from `A-Z a-z 0-9 . _ -- ? ! : @ % , [ ]`; a space, a newline or another control character exits 64, and nothing is printed or written.
-
podnet warp tun --default-route(CAP_NET_ADMIN) sends the whole host through WARP: a host route keeps each address of the MASQUE connection (the proxy, the front door, the relay) outside the TUN when the TUN would capture it. An address on loopback or on a connected subnet needs none, so a host with no default route works when its proxy is there; an address with no route at all is refused with its name.0.0.0.0/1,128.0.0.0/1,::/1and8000::/1go to the TUN; DNS goes to 1.1.1.1 (resolvectlon the TUN, else/etc/resolv.confwith a copy kept). The default routes go away while the session is down, so traffic fails fast. The end (Ctrl-C, SIGTERM,podnet stop) puts back each route and the resolver. The changes are ioctls; no script runs as root.podnet warp up --tunstarts it detached (instancewarp-tun), andpodnet warp downstops it. -
podnet warp tunmakes a TUN interface with the tunnel address and moves its packets through WARP. It does not change the routes: add routes for the destinations to send through WARP, and keep a route for the MASQUE endpoint outside the interface. The command prints examples. -
podnet warp proxy,podnet warp runandpodnet warp tunopen a new MASQUE session when the session ends (a network change, the front door, the limit of a relay): at once, then with a backoff of 1 s doubled to 30 s. A new client takes the new session; a client that comes while the session is down waits for it (20 s at most).warp tunkeeps its interface. The log hasa new WARP session is openfor each. -
When the direct MASQUE path fails (a proxy that refuses CONNECT to an IP address, or that answers and carries nothing), podnet opens the same MASQUE TLS through the WebSocket relay of
--relay(defaulthttps://tcp.ssh.relay.ajam.dev, see "Edge protocol and transport"). The client certificate and the pinned key stay end to end. The path that worked goes first at the next session.--no-relayturns this off.--masque-endpoint IP:PORTand--masque-sni NAMEreplace the front door and its TLS name. -
The registration API calls and the MASQUE connection use the proxy (see "Proxies and DNS"). A proxy that terminates TLS cannot carry the MASQUE connection, because the connection uses a client certificate.
podnet pod runs a program with all its
network traffic sent through a SOCKS5, HTTP or tailnet upstream. It does not
use LD_PRELOAD, so static binaries and Go binaries are also covered.
podnet pod -x socks5://127.0.0.1:1080 -- curl https://example.com
podnet pod -x http://proxy:3128 -- git clone https://example.com/repo
podnet pod -x socks5://unix:/run/proxy.sock -- ./static-binary
podnet pod -- curl https://example.com # upstream from $ALL_PROXY or $HTTPS_PROXY
podnet pod doctor # which backend can run hereTwo backends are available. --backend auto (the default) selects one.
| Backend | Needs | Mechanism |
|---|---|---|
netns |
unprivileged user namespaces and /dev/net/tun |
The program gets a network namespace. A TUN device is its only interface. A userspace TCP/IP stack sends each flow to the upstream. |
seccomp |
seccomp with SECCOMP_RET_USER_NOTIF |
A seccomp filter stops the socket system calls. The supervisor answers them and gives the program connected descriptors. |
Both backends answer DNS from a fake-IP pool (198.18.0.0/15; the
seccomp backend answers AAAA from fd63:6672:7300::/96 too) and send the
name to the upstream. Thus no DNS query leaves the host. See
crates/podnet-netns/README.md for all options.
podnet net gives programs IP addresses on a host that refuses AF_INET binds.
It needs no namespace, no TUN device and no capability.
podnet net doctor # what this host permits
podnet net shim --out /tmp/podnet-vnet # build the LD_PRELOAD shim
LD_PRELOAD=/tmp/podnet-vnet/libpodnet-vnet.so ./server # bind 10.66.0.2:8080 works
podnet net proxy --forward 10.66.0.2:8080=unix:/run/app.sock
podnet net switch --listen unix:/tmp/podnet-vnet.switch # connect stacks
podnet net proxy --switch unix:/tmp/podnet-vnet.switch --address 10.66.0.3A unix switch takes the same user only. A TCP switch needs a shared token:
set PODNET_SWITCH_TOKEN, or give --switch-token-file PATH, to the switch
and to each net proxy --switch tcp://.... With no token, the switch does
not start (exit 64). A peer with no token or a wrong one is closed. The
switch drops a packet whose source address is of another peer.
export PODNET_SWITCH_TOKEN="$(head -c 24 /dev/urandom | base64)"
podnet net switch --listen tcp://0.0.0.0:7000
podnet net proxy --switch tcp://switch-host:7000 --address 10.66.0.3podnet net doctor calls bind and then listen for each stream socket
(a policy can allow one and refuse the other; SO_ACCEPTCONN confirms the
listen), and sends one QUIC packet to the edge on UDP 7844: answered, refused
or ? (no answer in 3 s). It prints the commands that need each listener.
PODNET_UDP=off makes a tunnel use no QUIC on UDP 7844; PODNET_UDP=on skips
the probe at start.
podnet net tailscale --socket /run/tailscale/tailscaled.sock # SOCKS5 front door to the tailnet
eval "$(podnet net socksify --socket /tmp/podnet-socks.sock)" # shim for tailnet connects
podnet net ts-shims --out /run/podnet-ts-shims # user tables for Tailscale SSH
podnet pod -x tailscale:/run/tailscale/tailscaled.sock -- ssh hosttailscaled must run with --tun=userspace-networking.
Each connection of podnet (the quick-tunnel API, DNS over HTTPS, the HTTP/2 edge leg, the WARP API, MASQUE, the fetch of the public URL) takes the same proxy:
podnet --proxy socks5h://127.0.0.1:1080 tunnel --url http://127.0.0.1:8080
HTTPS_PROXY=http://user:password@proxy:3128 podnet demo --exit-on-verify
NO_PROXY=127.0.0.1,.internal podnet warp register
podnet --no-proxy tunnel --url http://127.0.0.1:8080- The order:
--proxy(orPODNET_PROXY), thenHTTPS_PROXY,https_proxy,ALL_PROXY,all_proxy. A variable that is empty or does not parse is skipped.--no-proxyuses none. - Percent-encode
/ ? # @ :in the user and the password of a proxy URL (for example%2Ffor/). http://usesCONNECT.socks5://resolves the name on this host;socks5h://gives the name to the proxy. podnet refusessocks4://andhttps://.- A proxy on a unix socket:
socks5h+unix:/PATH(alsosocks5h://unix:/PATH), orhttp+unix:/PATH. - A proxy at the stdin and the stdout of a command, when the only way out
is a byte channel (issue 129):
--proxy 'http+exec:ssh gw nc 127.0.0.1 3128'. Each connection starts the command once, withsh -c, as theProxyCommandof SSH; its stderr is the stderr of podnet. Only--proxyandPODNET_PROXYtake+exec:: a variable of another tool does not start a program. QUIC legs need UDP and do not use it. - Without
--proxyand--no-proxy, podnet readsHTTPS_PROXY,https_proxy,ALL_PROXYandall_proxy.NO_PROXYnames the targets that do not use the proxy. - When no proxy variable is set, podnet takes the proxy of the first tool
config that names one:
~/.gitconfigand~/.config/git/config(http.proxy),~/.npmrc(https-proxy, elseproxy),~/.config/pip/pip.confand~/.pip/pip.conf(proxy),/etc/gitconfig,/etc/pip.conf, and/etc/environment. The log names the file.--no-proxyturns it off. - With a proxy,
--protocol autodoes not try QUIC on UDP 7844 (no proxy carries UDP), and podnet prints one line that says so.--protocol quickeeps QUIC, with a warning. - With a user and a password, podnet sends Basic. A proxy that answers 407
with a Digest challenge (RFC 7616: MD5, MD5-sess, SHA-256,
SHA-256-sess,
qop=auth) gets the Digest answer on a new connection. Another 407 gets a message with the schemes that the proxy asks for. No NTLM.
A direct connection resolves the name with this chain: a pin of
--resolve HOST=IP or PODNET_RESOLVE, the system resolver, then DNS over
HTTPS by IP address at Cloudflare (1.1.1.1, 1.0.0.1) and Google (8.8.8.8,
8.8.4.4). It tries each address of the name, and starts the next attempt
250 ms after the one before.
podnet checks the certificate of each TLS server that it connects to. Behind a proxy that inspects TLS, give the root of the proxy:
SSL_CERT_FILE=/etc/corp-root.pem podnet tunnel --url http://127.0.0.1:8080
podnet --ca-file /etc/corp-root.pem warp register--ca-file and SSL_CERT_FILE replace the other roots. Without them, podnet
trusts podnet-ca.pem beside the binary, the first system bundle that it can
read (for example /etc/ssl/certs/ca-certificates.crt), and the Mozilla
roots compiled into podnet. podnet status shows the roots in tls_roots. See
SECURITY.md.
podnet agent up --expose 3000 # test, start WARP if needed, expose port 3000
podnet agent status
podnet agent down # stop each part, put back each changepodnet agent up runs the checks of podnet doctor --agent. When direct
networking does not do each task (git over HTTPS and SSH, the registries,
UDP DNS), it starts podnet warp up (--persist adds the shell block;
--warp starts it also when direct networking works). For each
--expose PORT, it starts a detached quick tunnel (instance
expose-PORT), and tries --transport relay when the first try fails.
It then runs the checks again and prints one JSON document: mode
(warp or direct), the paths that work for each task, the URLs, and
the commands to use (eval "$(podnet warp env)", podnet warp exec -- CMD,
podnet warp ssh-config).
When WARP was needed and podnet warp up failed, the document has
warp_error, and the exit code is 69. When an expose failed (its state
in exposed is not ready), the exit code is 69 too.
A host with outbound HTTPS only cannot take inbound connections (SSH, a
debugger, a database client). A quick tunnel carries HTTP and WebSocket
only. The reverse mode of the WebSocket relay (--relay, default
https://tcp.ssh.relay.ajam.dev) carries raw TCP bytes to a local service.
# On the host:
podnet relay pair # name, connect token; the node token stays in a 0600 file
podnet relay node --to 127.0.0.1:22 --detach # hold the node socket; one local connection for each session
# On the computer of the operator:
export PODNET_RELAY_CONNECT_TOKEN=<connect token>
ssh -o ProxyCommand='podnet relay connect NAME' USER@NAME
podnet relay connect NAME --listen tcp://127.0.0.1:2222 # or a local port for each client
# On the host, at the end:
podnet relay stop NAMEpodnet relay pairasks the relay for a pair (POST /v1/pair). A pair lasts at most 72 h. The file isrelay/NAME.jsonin the state directory, with mode 0600.--jsonprints the name, the connect token and the end time.podnet relay node [NAME] --to TARGETtakes the pair with the most time left when no name is given.TARGETishost:portorunix:/path. The node answers eachopenwithreadyafter its local connection is ready, or withrejectand the cause. A lost node socket is opened again after 1 s to 30 s, with jitter. The node warns 24 h, 6 h and 1 h before the pair expires. A node with no pair name takes the next stored pair at the expiry (a pair thatpodnet relay pairmade since), with no new start. Its operators need the name and the connect token of the next pair. When the relay ends the pair (it expired with no next pair, orpodnet relay stopran), the node exits 78 and is not started again.podnet relay connect NAMEwaits forready, then copies stdin and stdout. A refused session exits 69 with the cause from the node. A refused token exits 78. After the end of stdin, the session ends when the node closes, or 15 s (the idle time of the relay) after the last data. A close of the relay other than 1000 (for example 1009 at its byte limit) or a lost link exits 69, also after the end of stdin: the output can be cut.- The relay authenticates tokens, not persons. Give the connect token only over a channel that you trust. Check the host key of SSH as usual.
- The relay sets its own limits; podnet does not enforce them. Measured on
tcp.ssh.relay.ajam.dev: 64 MiB and 12 h for each session, and it can end a session after 180 s with no payload. Open a new session after a limit.
podnet tunnel --port 3000 --detach --name web # prints one JSON line, exits 0
podnet warp proxy --listen unix:/tmp/warp.sock --detach
podnet ps # name, state, pid, URL or listen address
podnet logs web -f
podnet wait web # the ready document again (exit 124 at --timeout)
podnet stop web # or: podnet stop --all--detach works with tunnel, serve, share, demo, connect,
warp proxy, warp forward, warp tun, relay node, net proxy and
net switch. The command runs in the foreground until it
is ready: until the public URL answers, or until the listener is bound.
Then it leaves the terminal (setsid, SIGHUP ignored, stdin to
/dev/null, stdout and stderr to its log), and the start prints one JSON
document on stdout: name, pid, log, and url or listen. The lines
of the instance before the ready go to stderr.
The instance keeps running when the shell or the terminal closes. A sandbox
or a container that kills each process of its run also ends it.
- A second start with the same name and the same arguments prints the same
document and starts nothing. Other arguments exit 69;
--replacestops the instance and starts the new one. - Without
--name, the name is the command and a hash of its arguments. --ready-timeout SECS(default 60): after it, the start stops the instance and exits 124 (astimeout). An instance that ends before it is ready gives its own exit code.- A token or a proxy password on the command line goes to the environment
of the instance, not to its arguments or its record. The record of a
foreground
--name(no--detach) has no secret either. - The files are in
$XDG_RUNTIME_DIR/podnet/run/(elserunin the state directory, orPODNET_RUN_DIR), mode 0700:NAME.json(0600),NAME.lock(held while the instance runs),NAME.secrets(0600, only when the instance has a token or a proxy password: the variables that hold them) andNAME.log(0600, cut at 1 MiB; the part before goes toNAME.log.1). Each start writes a linepodnet: ---- start TIME pid PID name NAME ----, then its log lines from the start (also the lines before the ready, which the start shows too). The log has no colour codes. - A supervisor starts the instance again when it ends, with a backoff of
1 s doubled to 30 s, but not after exit 64 or 78, and not before the first
ready. A planned end is not restarted:
--stop-after,--idle-secsand--run-forend the instance for good (a crash or a kill still restarts it). When the quick-tunnel service refused a provision with aRetry-After(HTTP 429), the next start waits that long (at most 1 h), and the run is not a quick failure. After 5 quick failures in sequence it runspodnet.prev(the binary before the lastpodnet update). A quick tunnel gets a new URL at a restart:podnet psshows it, and the log has a line for it.--no-restartturns the supervisor off.podnet stopstops the supervisor and its instance. - podnet never removes a lock file that a live process holds. When the
record of a live instance cannot be read,
podnet psshows the instance with the stateunknown, andpodnet stopexits 69 and removes nothing.podnet stop --allgoes on to the next instance after a failure. podnet autostart add NAME --from INSTANCEmakes a detached instance start at boot too. It copies the arguments of the record and the secrets ofINSTANCE.secrets.podnet statuslists the instances.--name NAMEwith no--detachregisters the process in the foreground (for a program that runs podnet as its child): it is not forked and not restarted, its stdout and stderr stay, and its log lines go toNAME.logtoo.podnet ps,logs,waitandstopsee it. A second process with the name fails ("runs already").--replaceneeds--detach.
podnet autostart starts a podnet command at boot and after a logout, with no
root. It uses a systemd user unit when systemctl --user answers, else an
@reboot line in the crontab. With neither, it prints the line to run at
login and exits 69.
TUNNEL_TOKEN=... podnet autostart add web -- tunnel --url http://127.0.0.1:8080
podnet autostart list
podnet autostart remove web- The arguments and the variables (
TUNNEL_TOKEN, the proxy variables,SSL_CERT_FILE,PODNET_*) go to private files (mode 0600) in<state>/autostart/.--tokenand a--proxywith a password move toTUNNEL_TOKENandPODNET_PROXY. The unit, the crontab line and the command line of the process hold no secret. - The unit and the line run this binary by its absolute path. A binary in
a temporary directory is refused. The unit quotes for systemd (
%and$are escaped), the crontab line forsh. - An entry runs in the foreground under its service manager:
addremoves--detach,--name,--ready-timeoutand--no-restart, and says so. - A systemd user manager stops at logout unless linger is on:
loginctl enable-linger. removedeletes only whataddwrote (a marker line in the unit, a marker comment on the crontab line).- Each program that
addorremoveruns has a limit of 10 s. - With no systemd user manager and no cron,
addexits 69. Its error text gives anohupline: put it in a login script (for example~/.profile).
podnet doctor tests the path from this host to the edge, and gives ok,
fail or unknown for each hop (the words of podnet net doctor and
podnet doctor --agent too). In --json, the key is status; the old key
verdict has the same value for one release:
- the proxy of each target (the password is never printed), and the
targets that
NO_PROXYsends direct; - TCP to
api.trycloudflare.com:443,api.cloudflareclient.com:443, the edge on 7844 by name and by address, the MASQUE front door (162.159.198.2:443) and the WebSocket relay (PODNET_RELAY). A name is resolved first, with the chain of podnet: the addresses and the time, or the error. Each path gets up to 9 samples, 125 ms apart, in at most 10 s: best, median, p95, jitter and loss. A refusal (a proxy 403, a TCP reset) is an answer, not a loss; - TLS on each open path: the issuer, and if the chain ends at the roots that podnet expects. A proxy that answers 200 and then sends no byte, and a proxy that terminates TLS, are named;
- names and addresses: if the proxy allows one and refuses the other;
- UDP 7844: one QUIC packet (answered, refused or unknown);
- the clock: this host against the
Dateof the first API that answers TLS. More than 300 s off isfail(certificates and tokens fail).
Then a table of the legs quic, http2, quic+warp and http2+relay
(TCP and TLS to the relay; the relay protocol is not tried), with the
reason for each leg that cannot work, and the roads: direct (quic or
http2), warp (quic+warp) and relay (http2+relay). The last line,
next:, gives the step to take: the podnet tunnel command for the legs
that work, the clock to set, or the report for an issue (next in
--json). Exit 0: at least one leg can work. Exit 1: none can. --json gives one document; --samples and --timeout
make it shorter.
podnet doctor
podnet doctor --json | jq '.legs'podnet doctor --agent # a table and the commands to run
podnet doctor --agent --jsonFor each task (git over HTTPS and over SSH, pip, npm, cargo, go modules,
docker pull), the doctor tries three paths: direct, the proxy of the
environment, and a running podnet warp up. A TLS check is a handshake
with the public roots: tls-intercepted means that the chain is not
public but the CA bundle of this host (SSL_CERT_FILE) trusts it, so a
proxy or a gateway terminates TLS. It also tests UDP DNS to 1.1.1.1,
IPv6 and TCP on 127.0.0.1, and prints the commands: for example
podnet warp up, podnet warp ssh-config, or "nothing to do".
podnet man shows the manual. It is generated from the command tree of the
binary, so it always agrees with --help. It needs no man(1), no groff and no
network.
podnet man # the whole manual, in a pager on a terminal
podnet man warp run # one command
podnet man exit-status # one topic: environment, files, exit-status, examples, pod
podnet man --roff > podnet.1 # a man(7) page (also a release asset)
podnet man --json # the tables, for a program
podnet status # one JSON line about this host; no network
podnet ports --json # the listening TCP ports, as one JSON object
podnet completions bash > ~/.local/share/bash-completion/completions/podnet
podnet -q tunnel ... # warnings and errors only; -v debug, -vv trace
podnet --log-format json ... # the log as one JSON object for each line
podnet --color never ... # colours: auto (a terminal, no NO_COLOR), always, neverpodnet raises the soft limit of open files (ulimit -n) to the hard limit at
start, at most 1048576. podnet status shows it in nofile. warp exec,
warp shell, warp run and autostart run keep the limit of the shell for
the program.
With --json, podnet tunnel, serve, share and demo write JSON lines on
stdout in place of the text lines. A line with "event": "ready" gives
url, tunnel_id, locations and leg (and pin for share). leg is
the edge leg that carries the tunnel: quic, http2, quic+warp or
http2+relay. The text edge line names it too. A line with
"event": "verified" comes after the fetch of the public URL. Its dns
field names the resolver step that resolves the public name at that time:
system, doh (public DNS resolves it, and the system resolver of this
host does not: a visitor whose resolver cached NXDOMAIN gets an error for
some seconds more), pin (--resolve) or none. While the fetch fails, podnet
logs the first try and each fifth try at INFO, and each try after the tenth
at WARN.
Stop signals: from the ready line (or the ready event) of a server
command on, SIGINT (Ctrl-C), SIGTERM and SIGHUP each start the clean
shutdown, also during the verify of the public URL and with --run-for.
A stop before the verified event gives no verified event. The exit
code is 0. A second signal during the shutdown stops podnet at once. Before
the ready, each signal has its default action. A detached instance ignores
SIGHUP. When the parent ignores SIGHUP (nohup), podnet ignores it too.
--check on tunnel, serve, share and demo checks the plan and stops.
It tests the flags and the config file, each origin (a connection, and the
TLS handshake for https; no request is sent), the files and the
directories (--dir, --spa and its index.html, --file, the upload
directory, --warp-identity, --credentials-file), PODNET_EDGE and the edge
legs (the legs row is the plan: podnet doctor tests the reach of each
leg). It makes no Cloudflare API call and no edge connection, opens no
listening socket and writes no file. It prints no token and no PIN. Exit 0:
each check passed. Exit 1: a check of an origin failed. Exit 78: each
failed check is a check of the configuration (PODNET_EDGE, the credentials
file, the roots). A bad command line (64) or a bad config file (78) stops
before the check. With --json, the output is one
document: {"ok", "plan", "checks": [{"name", "status", "detail"}]}.
podnet serve --check --dir ./site --spa
podnet tunnel --check --json --config config.yml | jq -e .okThe exit codes follow sysexits.h: 64 for a bad command line, 69 when a
service or a network path is not available (no edge leg worked), 78 for a
bad configuration (for example PODNET_EDGE). podnet man exit-status gives all
of them.
PODNET_ERROR_PROGRAM=/path/to/program gives a JSON report of each failure
(version, system, command, exit code, the last 64 log lines) to that program
on its stdin. Credentials are masked. The program does not get
TUNNEL_TOKEN, the relay tokens, or a proxy variable with a user and a
password in its environment. Use it to collect failures in tests.
use podnet::{Service, Tunnel};
# async fn run() -> anyhow::Result<()> {
let handle = Tunnel::quick()
.service(Service::parse("http://127.0.0.1:8080")?)
.ha_connections(2)
.build()?
.start()
.await?;
println!("{:?}", handle.url());
handle.verify(None).await?;
handle.shutdown().await;
# Ok(())
# }podnet::Ingressholds ordered rules for a tunnel with more than one service.podnet::warp::WarpClientregisters a device and opens aWarpStack, which hastcp_connectandudp_bindthrough the tunnel.podnet::vnet::NetStackis the userspace IP stack.podnet_netns::main_with_argsruns the command line ofpodnet pod. The modulespodnet_netns::upstream,socks,dnsandtailscaleare also public.
cargo build --releaseThe binary is target/release/podnet. Rust
1.99 or newer is necessary. The build takes the pod-* crates from the
git repository PodUtils/podcrates (by tag), so it needs access to
GitHub.
A static binary has no run-time dependency on the C library of the host:
rustup target add x86_64-unknown-linux-musl
CC_x86_64_unknown_linux_musl=clang cargo build --release --target x86_64-unknown-linux-muslThe LD_PRELOAD shims need a C compiler at run time (cc, gcc or
clang). The other commands do not.
fuzz/ has a libFuzzer target for each parser of bytes from the network
(Cap'n Proto, the RPC of the edge, capsules, proxy URLs and answers, SRV
and DoH, ACL rules, ingress rules, YAML, HTTP/2 headers, vnet frames, the
request head to the origin). It
needs a nightly toolchain and glibc. .github/workflows/fuzz.yml runs each
target each week. A crash input becomes a unit test before the fix.
cargo +stable install cargo-fuzz --locked --version 0.13.1
cd fuzz
cargo +nightly-2026-10-15 run --bin seed -- corpus
cargo +nightly-2026-10-15 fuzz run capnp_message corpus/capnp_message -- -max_total_time=60 -malloc_limit_mb=64cargo test runs each target on its seeds and on 2000 fixed mutations
(fuzzing::tests::each_target_survives_mutations).
scripts/rebuild.sh TAG [TARGET] rebuilds podnet of a
release from its tag, at another path, in the pinned image of
images/build/Dockerfile of the tag, with the command of release.yml.
The binary must have the SHA-256 of SHA256SUMS. TARGET is
x86_64-unknown-linux-musl or aarch64-unknown-linux-musl (the default is
the one of the machine). It needs git, curl and docker
(CONTAINER=podman for podman). release-proofs.yml runs it for both
targets after each release.
sh scripts/rebuild.sh <TAG>docs/protocol.md: the edge protocol and the WARP protocol.docs/virtual-network.md: the shim, the stack and switch mode.crates/podnet-netns/README.md: podnet-netns.docs/target-environment.md: the measured hosts, the other host conditions, and the rules of podnet for them.SECURITY.md: how to report a vulnerability, what Cloudflare can see, what listens, where secrets are kept.
0BSD. See LICENSE. See
THIRD_PARTY_NOTICES.md for the licenses of the
included third-party files.