libtunnel

package module
v0.0.25 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 5 Imported by: 0

README

libtunnel

Go Reference Go Report Card CI CodeQL OpenSSF Scorecard

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.

Quick Start

go get github.com/cnuss/libtunnel
package 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.)

Layout

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.

API at a glance

// 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
}

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

// parent→child handoff — no API: minting exports the LIBTUNNEL_SPEC env var,
// construction adopts it

Parent→child handoff

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.)

Replaying a spec

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)

Examples

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 subprocess

Testing

make 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 e2e

Contributing

See CONTRIBUTING.md for the local dev loop, release process, and what makes a good example.

License

MIT

Documentation

Overview

Package libtunnel exposes a local origin to the public internet through a tunnel backend (Cloudflare quick tunnels first), behind a thin, stable façade over stable/alpha versioned packages.

The package is split into these pieces:

  • libtunnel (this package) — thin façade exposing New and the backend constructors. Stable surface for application code.
  • github.com/cnuss/libtunnel/v1 — the stable Tunnel/Provider/Backend interfaces and spec types. Application code that wants to declare types against the contract imports this.
  • github.com/cnuss/libtunnel/v1alpha1 — the current implementation: the lazy tunnel core plus generic providers, with backend engines in subpackages (v1alpha1/cloudflare). Internals may change between alpha revisions.

Everything is lazy: New returns immediately, 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.

l, _ := net.Listen("tcp", "127.0.0.1:0")
conn := libtunnel.New(libtunnel.Cloudflare()).WithListener(l)
go server.Serve(conn.Listener())
select {
case <-conn.TunnelReady():
	fmt.Println(conn.URL()) // public https://<hostname>/
case <-conn.Done():
	log.Fatal(conn.Err()) // TunnelReady never closes on failure
}

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CacheDir added in v0.0.18

func CacheDir() string

CacheDir is where minted specs are cached and where From and Hosts look: LIBTUNNEL_CACHE_DIR if set, else a per-user location under os.UserCacheDir(). Empty if no per-user cache directory can be determined.

func Hosts added in v0.0.18

func Hosts() []string

Hosts lists the public URLs of the specs cached on disk — "https://<hostname>:443/" each, sorted — from LIBTUNNEL_CACHE_DIR if set, else a per-user location under os.UserCacheDir(). A mint caches its spec there, so this enumerates the tunnels From can replay. Best effort: an unreadable cache yields a shorter or empty list, never an error.

Types

type CloudflareV1 added in v0.0.7

type CloudflareV1 = v1.Backend[*cloudflare.Spec]

CloudflareV1 is the backend type returned by Cloudflare: an alias for v1.Backend[*cloudflare.Spec], re-exported so callers can name it without importing v1 or the cloudflare package.

func Cloudflare

func Cloudflare() CloudflareV1

Cloudflare returns the Cloudflare backend: an in-process cloudflared quick-tunnel engine (no cloudflared binary required). Its credential chain adopts a spec from the LIBTUNNEL_SPEC environment variable when a parent process handed one off, mints an anonymous *.trycloudflare.com quick tunnel otherwise, and exports a freshly minted spec back into the environment so spawned children inherit the same tunnel identity. A spec this process exported itself is never re-adopted: a second in-process tunnel mints its own identity.

type TunnelV1 added in v0.0.7

type TunnelV1 = v1.Tunnel

TunnelV1 is the tunnel handle returned by New: a non-generic alias for v1.Tunnel, re-exported so callers can name the type without importing v1. Storable as a plain field — the backend spec type does not appear in it.

func From added in v0.0.17

func From(spec string) TunnelV1

From returns an unstarted tunnel that replays a previously serialized spec instead of minting or adopting one — the credentials are pinned, so it connects under the same hostname. spec is resolved in order: an existing file at that path; a file of that name in the cache dir; a cached spec for that hostname (so From("foo.trycloudflare.com") replays the cached mint); finally the serialized JSON itself. The cache dir is LIBTUNNEL_CACHE_DIR when set, else a per-user location under os.UserCacheDir() — where a mint writes its spec.

Like New, it returns immediately and WithListener (or Listener) starts the connection. A spec that can't be parsed, or whose backend tag is unknown, yields a tunnel already canceled with that cause — surfaced through Err()/Done(), per the façade's no-error contract.

func New

func New[T v1.Spec](backend v1.Backend[T]) TunnelV1

New returns an unstarted tunnel on the given backend, which also supplies the credential chain. T is the backend's spec type, inferred from the backend and used only to wire the credential chain — it does not appear in the returned type, so the tunnel reference is non-generic and storable without threading the spec type through caller code:

libtunnel.New(libtunnel.Cloudflare())

Configure the result with With* methods; WithListener starts the connection.

Directories

Path Synopsis
Package e2e is the live tier: real quick tunnels through the real Cloudflare edge, gated behind LIBTUNNEL_E2E_LIVE=1.
Package e2e is the live tier: real quick tunnels through the real Cloudflare edge, gated behind LIBTUNNEL_E2E_LIVE=1.
examples
serve command
Command serve exposes a local HTTP server to the public internet through a Cloudflare quick tunnel, waits until the tunnel is reachable end to end, then requests its own public URL to prove the round trip.
Command serve exposes a local HTTP server to the public internet through a Cloudflare quick tunnel, waits until the tunnel is reachable end to end, then requests its own public URL to prove the round trip.
serve-tls command
Command serve-tls is serve's TLS twin: it hands WithListener a TLS listener (tls.Listen over a self-signed, in-process cert) instead of a plain one, so the tunnel dials the origin over https.
Command serve-tls is serve's TLS twin: it hands WithListener a TLS listener (tls.Listen over a self-signed, in-process cert) instead of a plain one, so the tunnel dials the origin over https.
subprocess command
Command subprocess is the canonical parent→child spec handoff: the parent process mints a tunnel spec and passes it to a spawned child through the LIBTUNNEL_SPEC environment variable; the child provides the listener, connects the tunnel, and serves; the parent then requests the child's public URL.
Command subprocess is the canonical parent→child spec handoff: the parent process mints a tunnel spec and passes it to a spawned child through the LIBTUNNEL_SPEC environment variable; the child provides the listener, connects the tunnel, and serves; the parent then requests the child's public URL.
Package v1 is the stable public surface for libtunnel.
Package v1 is the stable public surface for libtunnel.
Package v1alpha1 is the current implementation behind the v1 tunnel interfaces: the backend-agnostic lazy core plus generic providers.
Package v1alpha1 is the current implementation behind the v1 tunnel interfaces: the backend-agnostic lazy core plus generic providers.
cloudflare
Package cloudflare is the Cloudflare backend for libtunnel: a cloudflared quick-tunnel engine driven entirely in-process (no cloudflared binary).
Package cloudflare is the Cloudflare backend for libtunnel: a cloudflared quick-tunnel engine driven entirely in-process (no cloudflared binary).
resolver
Package resolver does direct DNS lookups against a specific server — the dig(1) equivalent: build the query with golang.org/x/net/dns/dnsmessage, send it over UDP (retrying over TCP when the answer is truncated), and parse the reply.
Package resolver does direct DNS lookups against a specific server — the dig(1) equivalent: build the query with golang.org/x/net/dns/dnsmessage, send it over UDP (retrying over TCP when the answer is truncated), and parse the reply.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL