libtunnel exposes a local origin to the public internet through a tunnel
backend — Cloudflare quick tunnels first, driven entirely in-process (no
cloudflared binary required).
The API is pure-lazy: every getter resolves on first use, and the edge
connection starts on first demand — WithListener provides the origin
listener explicitly, WithLocalURL points at an already-running local origin
instead (the cloudflared tunnel --url shape), and Listener, URL, and
TunnelReady mint a loopback listener if no origin was provided.
Configuration is write-once: each With* mutator takes effect at most once
and is a no-op after its value is fixed, whether by an earlier call or by the
tunnel's first use of the default.
go get github.com/cnuss/libtunnelpackage main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"github.com/cnuss/libtunnel"
)
func main() {
l, _ := net.Listen("tcp", "127.0.0.1:0") // you own the bind
conn := libtunnel.New(libtunnel.Cloudflare()).
WithContext(context.Background()). // URL waits for end-to-end readiness
WithListener(l) // lazily starts the edge connection
go http.Serve(conn.Listener(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello from libtunnel")
}))
url := conn.URL() // blocks until reachable end to end (see WithContext)
if url == nil {
log.Fatal(conn.Err())
}
fmt.Println(url) // https://<something>.trycloudflare.com/
}(The ingress scheme follows the listener: hand over a plain listener and the
origin is dialed over http; wrap it with tls.NewListener — or implement
TLS() bool on a custom listener — and the ingress switches to https,
self-signed certificates welcome.)
Three packages, stable/alpha versioning:
github.com/cnuss/libtunnel — root façade: New, backends,
providers, handoff helpers.
github.com/cnuss/libtunnel/v1 — stable Tunnel +
Provider[T]/Backend[T] contract.
github.com/cnuss/libtunnel/v1alpha1 — lazy tunnel core + generic
providers. May change between
alpha revisions.
github.com/cnuss/libtunnel/v1alpha1/cloudflare — the cloudflared quick-tunnel
engine + its Spec type.
Application code imports the root (libtunnel.New(...)). Code that needs to
declare types against the interfaces imports v1. Direct access to the
implementation lives in v1alpha1.
For the file-by-file map, see CONTRIBUTING.md → Where to find things.
// the lazy tunnel handle — every getter resolves on first use; the tunnel
// starts on first demand. Non-generic: the spec type is a construction-time
// detail, so a tunnel reference stores without threading T through caller code.
type Tunnel interface {
LocalPort() int // local side, inferred from the origin (listener or URL)
LocalIP() net.IP
LocalHost() string
LocalURL() *url.URL
Host() string // public side, derived from the spec
Hostname() string
Domain() string
Port() int
CACerts() []*x509.Certificate
Listener() net.Listener // start trigger: mints a loopback listener if none provided
URL() *url.URL // blocks until the hostname resolves (end-to-end w/ WithContext);
// start trigger, like Listener
HostnameReady() <-chan struct{} // hostname resolves on authoritative NS
TunnelReady() <-chan struct{} // connection up + hostname resolves;
// start trigger, like Listener
Done() <-chan struct{} // tunnel failed or shut down
Err() error // why (nil while alive)
// write-once mutators: first call wins, no-ops once the value is fixed
WithLogger(log *slog.Logger) Tunnel // default: silent
WithContext(ctx context.Context) Tunnel // URL waits end-to-end, honors ctx
WithListener(l net.Listener) Tunnel // bring your own listener
WithLocalURL(u *url.URL) Tunnel // attach to a running local origin
// (http://localhost:1234); mutually
// exclusive with WithListener
// hook requests in front of the origin proxy; layerable, not write-once
WithInterceptor(interceptor Interceptor) Tunnel
Interceptors() Interceptors // snapshot in precedence order (ascending Priority)
}
type Provider[T Spec] interface { Spec(ctx context.Context) (T, error) }
type Backend[T Spec] interface { // opaque; the engine contract is alpha-internal
Name() string
Provider() Provider[T] // the backend's credential chain
}
type Spec interface {
GetHostname() string
Serialize() string // tagged-envelope JSON; == a LIBTUNNEL_SPEC value
}
// façade
func New[T v1.Spec](backend v1.Backend[T]) v1.Tunnel // T wires the backend, not the result
func Cloudflare() v1.Backend[*cloudflare.Spec] // in-process cloudflared engine;
// adopts LIBTUNNEL_SPEC, else mints
// an anonymous quick tunnel
func From(spec string) v1.Tunnel // replay a serialized spec (JSON,
// file path, or cached hostname)
func Hosts() []string // public URLs of cached specs
func Version() string // the libtunnel release this build
// links against (matches the git tag
// and the container image tag)
// parent→child handoff — no API: minting exports the LIBTUNNEL_SPEC env var,
// construction adopts itAn in-process reverse proxy always fronts the origin. WithInterceptor hooks
that path: for every request the tunnel runs the highest-Priority interceptor
whose MatchFn returns true; anything unmatched is proxied to the origin
unchanged. Ordering is AWS-ALB style: the lowest Priority wins — 1 is the
highest precedence, 65535 the lowest. Priority 0 is not a precedence; it's
the zero value meaning unset, and those are auto-assigned from the top of the
uint16 range downward, so unprioritized interceptors sit at the low-precedence
end — a later-added one wins over an earlier one, and any explicit Priority
outranks them all. Interceptors layer (call it more than once) and, unlike the
write-once With* mutators, may be added after the tunnel is live.
tun.Interceptors() returns the registry in precedence order for visibility.
type MatchFn = func(r *http.Request) bool
type InterceptFn = func(ctx InterceptCtx) InterceptCtx
// WithInterceptor takes an Interceptor — the {Match, Handler} pair — so reusable
// interceptors ship as constructors: tun.WithInterceptor(addHeaders()).
type Interceptor struct {
Match MatchFn
Handler InterceptFn
Priority uint16 // ALB-style: lowest wins (1 highest); 0 = unset, auto-assigned from the top down
}
// InterceptCtx is the per-request handle. It embeds the request's
// context.Context and carries the request, the response writer, and the
// handler that will serve it — seeded to proxy the origin.
type InterceptCtx interface {
context.Context
Reconnect(ctx context.Context) error // cycle the edge conn(s), block until back up
Target() net.Listener // the proxy's loopback socket (not the origin)
WithHandler(h http.HandlerFunc) InterceptCtx // replace the serving handler
Handler() http.HandlerFunc // the handler currently set
Writer() http.ResponseWriter
Request() *http.Request
}An InterceptFn receives the InterceptCtx and shapes how the request is
served: call WithHandler to take it over, or return the ctx unchanged (or
nil) to leave the default in place — the request is proxied to the origin,
exactly as if nothing had matched. So an interceptor can match broadly, inspect,
and opt out per request.
Ship an interceptor as a constructor that returns an Interceptor. The common
shape wraps the default handler (ic.Handler(), which proxies to the origin) —
plain net/http middleware:
// addHeader sets a response header on every request, then serves the origin.
func addHeader(key, val string) libtunnel.Interceptor {
return libtunnel.Interceptor{
Match: func(*http.Request) bool { return true },
Handler: func(ic libtunnel.InterceptCtx) libtunnel.InterceptCtx {
next := ic.Handler() // the default: proxy to the origin
return ic.WithHandler(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(key, val)
next(w, r)
})
},
}
}
tun := libtunnel.New(libtunnel.Cloudflare()).WithListener(l)
tun.WithInterceptor(addHeader("X-Served-By", "libtunnel"))Reach past the request through the ctx levers — e.g. force an edge reconnect on
?watch=true, then serve normally:
func reconnectOnWatch() libtunnel.Interceptor {
return libtunnel.Interceptor{
Match: func(r *http.Request) bool { return r.URL.Query().Get("watch") == "true" },
Handler: func(ic libtunnel.InterceptCtx) libtunnel.InterceptCtx {
next := ic.Handler()
return ic.WithHandler(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(ic, 30*time.Second)
defer cancel()
if err := ic.Reconnect(ctx); err != nil {
return // request can't proceed — stop, no further writes
}
next(w, r)
})
},
}
}LIBTUNNEL_SPEC is a first-class handoff channel with nothing to call: when
the Cloudflare credential chain mints a spec it exports it into the
process's environment, and at construction it adopts one found there. A
spawned child (or a re-exec) therefore connects under the same hostname —
no second quick-tunnel resolution, no plumbing. The export also sets
LIBTUNNEL_HOSTNAME to the plain hostname, so tooling can read it without
parsing the envelope (libtunnel itself adopts LIBTUNNEL_SPEC, not this).
Two guardrails keep the channel safe: a process never re-adopts a spec it exported itself (a second tunnel in the same process mints its own identity instead of inheriting the first one's), and the exported value is tagged with the backend that minted it, so a child running a different backend fails loudly instead of silently unmarshaling a foreign spec.
// parent: forcing the mint exports LIBTUNNEL_SPEC as a side effect (never
// connects itself); Hostname triggers the mint and returns the public name
libtunnel.New(libtunnel.Cloudflare()).Hostname()
cmd := exec.Command(os.Args[0], "child") // inherits the environment
// child: the Cloudflare credential chain finds LIBTUNNEL_SPEC and adopts it
conn := libtunnel.New(libtunnel.Cloudflare()).WithListener(l)(Full source: examples/subprocess/main.go.)
A freshly minted spec is cached to disk as <hostname>.spec.json (the
Serialize() envelope, same form as LIBTUNNEL_SPEC) under the cache dir —
LIBTUNNEL_CACHE_DIR if set, else a per-user location from os.UserCacheDir().
libtunnel.Hosts() lists the cached tunnels as https://<host>:443/ URLs, and
libtunnel.From(spec) replays one — spec is the JSON, a file path, or just a
cached hostname — connecting under the same hostname instead of minting. Only
minted specs are cached (adopted or From-loaded ones are not), and a
bad/unknown spec yields a tunnel already canceled with the cause (off Err()).
// replay the most recently cached tunnel by hostname
conn := libtunnel.From("foo.trycloudflare.com").WithListener(l)Every code knob with an env-expressible value has an environment mirror, and env beats code: an operator can redirect a deployed binary without a rebuild. (The one exception is noted below.)
| Variable | Mirrors | Behavior |
|---|---|---|
LIBTUNNEL_SPEC |
— | Parent→child handoff: a serialized spec adopted at construction (see above). Beats everything, including a code-pinned From spec. |
LIBTUNNEL_FROM |
From() |
Replay a spec by hostname, file path, or literal JSON — From's resolution. Applies after LIBTUNNEL_SPEC, before the code-pinned spec and minting. |
LIBTUNNEL_LOCAL_URL |
WithLocalURL() |
Origin override, applied at origin-provide time: supersedes a WithListener listener, a WithLocalURL argument, and the start-trigger mint. Invalid value cancels the tunnel. |
LIBTUNNEL_TLS |
WithTLS() |
Bool (strconv.ParseBool). Fixed at backend construction; later WithTLS calls are no-ops. Unparsable value fails at connect. |
LIBTUNNEL_HTTP2 |
WithHTTP2() |
Same rules as LIBTUNNEL_TLS. |
LIBTUNNEL_LOG |
WithLogger() |
debug|info|warn|error: the default logger becomes a stderr text logger at that level instead of silent. The exception: an explicit WithLogger keeps its handler — env carries a level, not a sink. |
LIBTUNNEL_HOSTNAME |
— | Export-only mirror of the minted spec's hostname, for tooling; never adopted. |
LIBTUNNEL_CACHE_DIR |
— | Where minted specs are cached and From/Hosts look. |
Backend-scoped variables follow LIBTUNNEL__<BACKEND>_<FIELD> (double
underscore namespaces the backend) and live with their backend package. For
Cloudflare, each mirrors a spec-field setter on the backend — env beats code,
field by field, patched onto whatever spec the chain resolves; a complete
credential set (id, hostname, account tag, secret) skips resolution entirely:
| Variable | Mirrors |
|---|---|
LIBTUNNEL__CLOUDFLARE_ID |
WithID() |
LIBTUNNEL__CLOUDFLARE_NAME |
WithName() |
LIBTUNNEL__CLOUDFLARE_HOSTNAME |
WithHostname() |
LIBTUNNEL__CLOUDFLARE_ACCOUNT_TAG |
WithAccountTag() |
LIBTUNNEL__CLOUDFLARE_SECRET |
WithSecret() (base64) |
LIBTUNNEL__CLOUDFLARE_PROVIDER |
WithProvider() — quick-tunnel provider host, default api.trycloudflare.com (endpoint https://<host>/tunnel synthesized; a value with a scheme is used verbatim) |
The Cloudflare backend also has a bare activation switch, LIBTUNNEL__CLOUDFLARE=1,
used by the binary below to select it without a spec handoff.
cmd/libtunnel is a standalone launcher configured
only by the environment — no flags, no config files — the operator-side
face of the variables above. Shaped for docker run -e ...:
go run ./cmd/libtunnel
# or: go build -o libtunnel ./cmd/libtunnelIt needs two things:
- a backend, activated by
LIBTUNNEL_SPEC(a spec handoff) orLIBTUNNEL__CLOUDFLARE=1(the explicit switch); and - an origin,
LIBTUNNEL_LOCAL_URL— a standalone binary has no listener to inherit, so it points at an already-running local service.
LIBTUNNEL__CLOUDFLARE=1 \
LIBTUNNEL_LOCAL_URL=http://localhost:8080 \
LIBTUNNEL_LOG=info \
libtunnelIt prints the public URL to stdout (one line; logs go to stderr via
LIBTUNNEL_LOG) and runs until SIGINT/SIGTERM. Every other knob —
LIBTUNNEL_TLS, LIBTUNNEL_FROM, the LIBTUNNEL__CLOUDFLARE_* fields —
flows straight through the library. Minting also exports
LIBTUNNEL_SPEC/LIBTUNNEL_HOSTNAME, so a child it later spawns inherits the
same tunnel identity. libtunnel version prints the build id and exits — the
only argument it accepts, since configuration is environment-only.
Each release attaches static, stripped binaries for linux/darwin/windows ×
amd64/arm64, a SHA256SUMS manifest, and a cosign .sigstore bundle per
file. To build locally instead: make dist (cross-compiles the matrix into
dist/) or make binary (host only). CGO is off, so the binary is
dependency-free and runs on a scratch/distroless base — Dockerfile
targets distroless/static:nonroot. Each release publishes a multi-arch
(amd64/arm64), cosign-signed image to the GitHub Container Registry:
docker run --rm \
-e LIBTUNNEL__CLOUDFLARE=1 \
-e LIBTUNNEL_LOCAL_URL=http://host.docker.internal:8080 \
ghcr.io/cnuss/libtunnel:latestOr build it yourself: docker build -t libtunnel .
The image is tagged with the release version (v0.0.29), the bare semver
(0.0.29, 0.0), and latest. libtunnel.Version() returns the v-prefixed
tag, so a consumer can pin the image to the exact library version it links
against without any string munging:
image := "ghcr.io/cnuss/libtunnel:" + libtunnel.Version()Self-contained programs in ./examples:
| Example | Demonstrates |
|---|---|
serve |
Real quick tunnel: serve locally, request the public URL. |
serve-tls |
Same as serve, but a TLS listener (tls.Listen) — ingress flips to https. |
subprocess |
Parent mints a spec; child adopts it via LIBTUNNEL_SPEC and serves. |
Run one locally:
make run serve
make run serve-tls
make run subprocessmake test # library unit + fuzz tests (fast, in-package)
make e2e # live tier: real tunnels through the real edge (gated)make e2e runs go test -count=1 -v ./e2e. The -count=1 defeats the test
cache, since the harness builds the example binaries at runtime and the cache
key wouldn't otherwise pick up example source changes. The e2e tier is live
tunnels only — everything mints from api.trycloudflare.com (rate-limited),
so the whole tier is skipped unless you opt in (offline subprocess handoff
coverage lives in the unit tier and always runs):
LIBTUNNEL_E2E_LIVE=1 make e2eSee CONTRIBUTING.md for the local dev loop, release process, and what makes a good example.