h2tunnel

package module
v1.0.20260916 Latest Latest
Warning

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

Go to latest
Published: Sep 15, 2026 License: GPL-3.0 Imports: 44 Imported by: 0

README

h2tunnel

Test

h2tunnel is a secure tunneling library embeddable in Go programs, and it also ships a standalone command-line program. It wraps TCP or UDP services inside HTTP/2, HTTP/3, WebTransport, MASQUE, or gRPC connections, with disconnect recovery, CDN-friendly request headers, and bounded session buffering.

The server does not become an open proxy by default: the package API forces callers to provide both an Authenticator and a TargetDialer. Prefer logical service names (for example ssh, postgres) and do not let the client decide arbitrary target addresses.

Installation

Use it as a Go package:

go get github.com/NNdroid/h2tunnel

One-line install of the command-line program (Linux, includes systemd service registration):

curl -fsSL https://raw.githubusercontent.com/NNdroid/h2tunnel/main/scripts/install.sh | sudo bash -s -- install

The script prefers a local prebuilt binary, then builds from source, and finally downloads the bare binary matching the system architecture from a GitHub Release (no extraction needed).

Build the command-line program from source:

go build -trimpath -o h2tunnel ./cmd/h2tunnel

Choosing a transport

Transport TCP UDP Plain CDN Typical use
h2 ✅ ✅ ✅ recommended CDN, reverse proxy, general public access
h2c ✅ ✅ plaintext origin links only internal networks, TLS terminated at an external gateway
grpc ✅ ✅ ✅, requires CDN with gRPC enabled existing gRPC infrastructure
h3 ✅ ✅ usually no origin forwarding end-to-end QUIC direct connection
masque ✅ ✅ usually no origin forwarding standard CONNECT-TCP/UDP direct connection
wt ✅ ✅ usually no origin forwarding WebTransport streams carrying TCP byte streams and UDP datagrams

Plain CDNs do not forward UDP/QUIC verbatim to the origin, so for CDN scenarios prefer h2; H3, WebTransport, and MASQUE should be used as end-to-end direct connections.

Package API overview

Client:

func NewClient(ClientOptions) (*Client, error)
func (*Client) Start(context.Context) error
func (*Client) DialContext(context.Context, string, string) (net.Conn, error)
func (*Client) DialPacketContext(context.Context, string, string) (PacketConn, error)
func (*Client) Shutdown(context.Context) error
func (*Client) Close() error

Server:

func NewServer(ServerOptions) (*Server, error)
func (*Server) Handler() http.Handler
func (*Server) Serve(Listeners) error
func (*Server) ListenAndServe(string) error
func (*Server) Listeners() Listeners
func (*Server) Shutdown(context.Context) error
func (*Server) Close() error

Security helpers:

func NewTokenCredentials(string) (CredentialProvider, error)
func NewTokenAuthenticator(string) (Authenticator, error)
func NewStaticServiceDialer(map[string]Service, *net.Dialer) (TargetDialer, error)

Client is safe for concurrent use; each dial owns an independent logical session. Server is a single-lifetime object — create a new instance after closing it. NewClient and NewServer only validate configuration; they do not open ports or start background tasks.

If Client.Start fails it releases all transport resources and resets state, so Start can simply be called again to retry; after success, calling Start again returns the first result. Client.Shutdown returns once the context deadline passes, but existing tunnels keep draining in the background — call Close to force every active connection down when you need an immediate stop.

Server.Listeners() returns the listeners Serve actually bound; with port 0 you can read the real port via Listeners().QUIC.LocalAddr() (a WT-only deployment has no TCP listener, so this is the only port-discovery path). The values are for reading addresses only; listener ownership stays with Serve.

Full package API example

1. Build a closed service registry

The server below only allows access to two explicitly registered targets. Unknown service names, network-type mismatches, or insufficient roles are rejected.

package main

import (
    "context"
    "log"
    "net/http"

    "github.com/NNdroid/h2tunnel"
)

func main() {
    tokenAuth, err := h2tunnel.NewTokenAuthenticator("replace-with-a-long-random-token")
    if err != nil {
        log.Fatal(err)
    }
    auth := func(ctx context.Context, request *http.Request) (h2tunnel.Principal, error) {
        principal, err := tokenAuth(ctx, request)
        if err != nil {
            return h2tunnel.Principal{}, err
        }
        principal.ID = "operations-client"
        principal.Roles = []string{"ops"}
        return principal, nil
    }

    dialer, err := h2tunnel.NewStaticServiceDialer(map[string]h2tunnel.Service{
        "ssh": {
            Network: h2tunnel.NetworkTCP,
            Address: "127.0.0.1:22",
            Roles:   []string{"ops"},
        },
        "dns": {
            Network: h2tunnel.NetworkUDP,
            Address: "127.0.0.1:53",
        },
    }, nil)
    if err != nil {
        log.Fatal(err)
    }

    server, err := h2tunnel.NewServer(h2tunnel.ServerOptions{
        Path:          "/tunnel",
        Transports:    []h2tunnel.Transport{h2tunnel.TransportH2},
        Networks:      []h2tunnel.Network{h2tunnel.NetworkTCP, h2tunnel.NetworkUDP},
        Authenticator: auth,
        Dialer:        dialer,
        Tuning: h2tunnel.ServerTuning{
            Padding: h2tunnel.PaddingTuning{
                MinRecordBytes: 600,
                MaxRecordBytes: 1200,
            },
        },
    })
    if err != nil {
        log.Fatal(err)
    }

    // TLS is terminated at the CDN/Nginx; the origin listens on plain HTTP on the loopback address.
    origin := &http.Server{Addr: "127.0.0.1:8080", Handler: server.Handler()}
    log.Fatal(origin.ListenAndServe())
}

TransportH2 is allowed here instead of TransportH2C because client-to-CDN uses H2; even if CDN-to-origin degrades to HTTP/1.1, it still belongs to the H2 POST-stream transport family. The origin must listen only on a trusted network or the loopback address.

2. Create a client and dial a logical service
credentials, err := h2tunnel.NewTokenCredentials("replace-with-a-long-random-token")
if err != nil {
    return err
}
client, err := h2tunnel.NewClient(h2tunnel.ClientOptions{
    Endpoint:    "https://tunnel.example.com",
    Path:        "/tunnel",
    Transport:   h2tunnel.TransportH2,
    Credentials: credentials,
    Tuning: h2tunnel.ClientTuning{
        SessionWindowBytes: 256 * 1024,
        HeartbeatInterval:  25 * time.Second,
        StandbyConnections: 1,
        Padding: h2tunnel.PaddingTuning{
            MinRecordBytes: 600,
            MaxRecordBytes: 1200,
        },
    },
})
if err != nil {
    return err
}
defer client.Close()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, err := client.DialContext(ctx, h2tunnel.NetworkTCP, "ssh")
if err != nil {
    return err
}
defer conn.Close()

DialContext returns only after server authentication, target authorization, target connection, and the tunnel handshake have all completed. The passed context propagates all the way to the server-side TargetDialer; a timeout or cancellation leaves no target connection still dialing in the background.

3. Event callbacks and network-change self-heal (optional)
client.SetEventHandler(func(ev h2tunnel.ClientEvent) {
    switch ev.Kind {
    case h2tunnel.EventReconnecting:
        log.Printf("tunnel reconnecting (attempt %d): %v", ev.Attempt, ev.Err)
    case h2tunnel.EventTunnelDied:
        log.Printf("tunnel died: %s", ev.Reason)
    }
})

// When an OS network-change notification arrives (NotifyAddrChange / NWPathMonitor etc.):
client.ForceReconnect() // abandon the current stream and redial immediately; session/data not lost

Callbacks are dispatched on a dedicated goroutine with panic recovery and never block the packet-read loop. Each tunnel exposes Done() <-chan struct{} and Err() error (context-style lifecycle).

Self-heal tuning (ClientTuning):

  • AutoRedial: true — automatically resets and continues after redial exhaustion (16 attempts), essential for "stay down until the network returns" scenarios; when off, exhaustion terminates the tunnel and dispatches a TunnelDied event.
  • RedialBudget — per-attempt dial budget for stream setup + handshake, tightening the abandon pace during outages; the timer stops once the tunnel is ready and never affects established streams.
  • SessionWindowBytes — when outage duration × downlink rate exceeds the window, the gap is unrecoverable; raise it for long outages / high throughput.
4. Make http.Client reach services uniformly through the tunnel
transport := &http.Transport{DialContext: client.DialContext}
httpClient := &http.Client{Transport: transport, Timeout: 30 * time.Second}

// The URL's host is passed to the server registry as the logical target.
response, err := httpClient.Get("http://internal-api/health")

If the logical name includes a port, use the same string as the registry key, e.g. internal-api:80. Response bodies are still managed by the ordinary http.Client.

5. Build an SSH client on top of a reused tunnel
raw, err := client.DialContext(ctx, h2tunnel.NetworkTCP, "ssh")
if err != nil {
    return err
}
sshConn, channels, requests, err := ssh.NewClientConn(raw, "ssh", sshConfig)
if err != nil {
    raw.Close()
    return err
}
sshClient := ssh.NewClient(sshConn, channels, requests)
defer sshClient.Close()
6. UDP / datagram access
packetConn, err := client.DialPacketContext(ctx, h2tunnel.NetworkUDP, "dns")
if err != nil {
    return err
}
defer packetConn.Close()

_ = packetConn.SetDeadline(time.Now().Add(5 * time.Second))
if _, err := packetConn.Write(dnsQuery); err != nil {
    return err
}
response := make([]byte, 64*1024)
n, err := packetConn.Read(response)

What comes back is a "connected" PacketConn: the address argument of WriteTo cannot change the logical target fixed at creation time. Dial a separate PacketConn per remote UDP conversation.

7. Application-layer record padding

PaddingTuning applies to both TCP byte streams and UDP datagrams, and covers all six transports h2, h2c, grpc, h3, wt, masque. To shape both uplink and downlink, configure it on client and server alike:

padding := h2tunnel.PaddingTuning{
    MinRecordBytes: 600,
    MaxRecordBytes: 1200,
}

clientOptions.Tuning.Padding = padding // client to server
serverOptions.Tuning.Padding = padding // server to client

Stream data is sliced into full records whose lengths fall randomly in [600, 1200]; short records get padding appended. UDP always keeps one packet per record: short packets are padded, and packets originally above the cap stay intact and are never split. Padding is discarded by the peer after decoding, so the business payload the TCP/UDP target receives is unchanged. Omitting the config or setting both values to 0 disables padding entirely; setting only MinRecordBytes makes the cap default to 125% of the minimum.

What is guaranteed here is the h2tunnel application-layer record size, not the size of every IP packet on the wire. TLS, HTTP/2, HTTP/3, QUIC, TCP ACKs/retransmissions, CDNs, path MTU, and TSO/GSO may still split or coalesce records; no application can guarantee every actual IP packet is at least 600B. To verify the live packet-size distribution, capture on the target interface with NIC segmentation offload disabled.

8. Custom authentication and dynamic routing

Production systems can turn JWT, mTLS identity, or an existing session into a stable Principal.ID, then enforce tenant, role, network, and target policies inside the TargetDialer.

authenticator := func(ctx context.Context, r *http.Request) (h2tunnel.Principal, error) {
    claims, err := verifyJWT(r.Header.Get("Authorization"))
    if err != nil {
        return h2tunnel.Principal{}, h2tunnel.ErrUnauthenticated
    }
    return h2tunnel.Principal{ID: claims.Subject, Roles: claims.Roles}, nil
}

targetDialer := func(ctx context.Context, request h2tunnel.DialRequest) (net.Conn, error) {
    address, ok := lookupAllowedService(request.Principal.ID, request.Target, request.Network)
    if !ok {
        return nil, h2tunnel.ErrForbidden
    }
    var dialer net.Dialer
    return dialer.DialContext(ctx, string(request.Network), address)
}

Never dial request.Target without validation inside the TargetDialer, or the tunnel becomes an SSRF/open internal proxy. For UDP, the TargetDialer must return a connected datagram net.Conn, typically a *net.UDPConn.

9. Direct TLS with H2/H3 sharing one port
certificate, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
    return err
}
server, err := h2tunnel.NewServer(h2tunnel.ServerOptions{
    Path:       "/tunnel",
    Transports: []h2tunnel.Transport{h2tunnel.TransportH2, h2tunnel.TransportH3},
    Networks:   []h2tunnel.Network{h2tunnel.NetworkTCP, h2tunnel.NetworkUDP},
    TLSConfig: &tls.Config{
        MinVersion:   tls.VersionTLS13,
        Certificates: []tls.Certificate{certificate},
    },
    Authenticator: authenticator,
    Dialer:        targetDialer,
})
if err != nil {
    return err
}

// Automatically creates TCP and UDP listeners on the same numeric port.
return server.ListenAndServe(":8443")

The library never persists certificates. When ListenAndServe hosts h2/h3/wt/masque you must supply a TLSConfig containing a certificate; for development use h2tunnel.SelfSignedTLSConfig("localhost") to generate one on the fly (it always carries 127.0.0.1/::1 IP SANs so loopback connections pass verification directly); for production use publicly trusted certificates. When embedding via Handler into an existing HTTP server, TLS can be handled by the external server or reverse proxy.

10. Manage listeners yourself
tcpListener, err := net.Listen("tcp", ":8443")
if err != nil {
    return err
}
udpListener, err := net.ListenPacket("udp", ":8443")
if err != nil {
    tcpListener.Close()
    return err
}
err = server.Serve(h2tunnel.Listeners{TCP: tcpListener, QUIC: udpListener})

Serve takes over the passed listeners; an unexpected failure of either listener stack closes the other stack and returns the error.

11. Graceful shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := client.Shutdown(shutdownCtx); err != nil {
    _ = client.Close() // force-close after timeout
}
if err := server.Shutdown(shutdownCtx); err != nil {
    _ = server.Close()
}

Shutdown refuses new sessions and waits for existing connections to end naturally; Close terminates immediately. When embedded in an external http.Server, stop the external server from accepting new requests first, then call the tunnel server's Shutdown.

CDN and reverse-proxy deployment

Recommended path:

app -> h2tunnel Client -> HTTPS/H2 -> CDN -> HTTPS/HTTP origin -> h2tunnel Server -> internal service

Tunnel requests and responses set the following key properties:

  • Cache-Control: no-store, no-transform
  • Content-Type: application/octet-stream
  • Content-Encoding: identity
  • Accept-Encoding: identity
  • User-Agent: a real browser UA (camouflaged as Android Chrome WebView by default, suppressing Go's Go-http-client/2.0 default; pairs with utls browser TLS fingerprints)
  • X-Accel-Buffering: no
  • X-Auth-Token, plus a standard Bearer Authorization as well

These settings stop proxies from caching, compressing, or buffering binary streams. The server never advances the recovery cursor on non-2xx responses, auth failures, or proxy-substituted error pages.

Nginx: TLS to the origin

The standalone CLI's h2 mode listens with TLS at the origin. Nginx can proxy like this:

location /tunnel {
    proxy_pass https://127.0.0.1:8443;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_request_buffering off;
    proxy_cache off;
    gzip off;
    proxy_set_header Host $host;
    proxy_set_header X-Auth-Token $http_x_auth_token;
    proxy_set_header Authorization $http_authorization;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 1h;
    proxy_send_timeout 1h;
    # CLI-generated certificates are only fit for a protected local origin link.
    proxy_ssl_verify off;
}

For a plaintext origin, use the Server.Handler() embedding example above and let the external server listen only on 127.0.0.1 or a protected private address. Do not allow the CDN to cache /tunnel, and do not enable request/response buffering.

The heartbeat interval must be smaller than the shortest idle timeout on the path. The default 25s suits common 60s proxy timeouts; adjust explicitly if the CDN's shortest timeout differs. Controlled proxy latency, error responses, auth-header forwarding, cache/buffer headers, and steady-state throughput are all covered by automated tests.

CLI usage

Server configuration
{
  "mode": "server",
  "listen": ":8443",
  "path": "/tunnel",
  "transport": "h2",
  "network": "tcp",
  "token": "replace-with-a-long-random-token",
  "tls": true,
  "cert": "/usr/local/etc/h2tunnel/server.crt",
  "key": "/usr/local/etc/h2tunnel/server.key",
  "local_only": true,
  "session_window_kb": 256,
  "drain_timeout_sec": 30,
  "padding": {
    "min_record_bytes": 600,
    "max_record_bytes": 1200
  },
  "log_level": "info"
}

When cert and key are both empty, the CLI generates an in-process self-signed certificate; for public production origins provide a real certificate. local_only: true resolves the target host and rejects any non-loopback address, reducing SSRF risk.

Client configuration
{
  "mode": "client",
  "listen": "127.0.0.1:2222",
  "server": "https://tunnel.example.com",
  "target": "127.0.0.1:22",
  "path": "/tunnel",
  "transport": "h2",
  "network": "tcp",
  "token": "replace-with-a-long-random-token",
  "sni": "tunnel.example.com",
  "host": "tunnel.example.com",
  "insecure": false,
  "utls": "chrome",
  "heartbeat_sec": 25,
  "session_window_kb": 256,
  "handshake_ack_ms": 3000,
  "keepalive_sec": 15,
  "standby_connections": 1,
  "drain_timeout_sec": 30,
  "padding": {
    "min_record_bytes": 600,
    "max_record_bytes": 1200
  },
  "log_level": "info"
}

Starting it:

h2tunnel -c /usr/local/etc/h2tunnel/config.json
h2tunnel server -c /usr/local/etc/h2tunnel/config.json
h2tunnel client -c /usr/local/etc/h2tunnel/config.client.json
h2tunnel version

The client's listen is the TCP/UDP entry given to local programs, and target is the address the server will ultimately connect to. The CLI is a direct-address proxy; use the package API when you need logical service registries, per-identity routing, or embedding into another program uniformly.

Configuration fields

Config parsing is strict: unknown fields, removed fields, wrong types, and fields with no effect in the current mode all fail hard, with no backward compatibility for old versions.

Field Mode Default Description
mode shared server server or client
listen shared server :8443; client 127.0.0.1:2222 listen address
server client required full http:// or https:// server address
target client required target address the server should connect to
path shared /tunnel tunnel HTTP path; MASQUE endpoints are nested beneath it: <path>/.well-known/masque/{tcp,udp}/... (e.g. path=/tunnel → /tunnel/.well-known/masque/...; path=/ yields the standard /.well-known/masque)
token shared empty pre-shared auth token; must be set in production
transport shared server h2; client inferred from URL server accepts comma-separated lists or all; client picks exactly one
network shared tcp tcp, udp, or all
tls server false enable TLS; h2/h3/wt/masque imply TLS automatically, h2c forces plaintext
cert / key server empty TLS certificate and private key, must be set together
local_only server false allow loopback targets only
insecure client false skip certificate verification, for controlled testing only
host client empty override the HTTP Host, for CDN multi-tenant origin routing
sni client URL hostname override the TLS SNI
utls client empty TLS ClientHello fingerprint camouflage: chrome, firefox, edge, safari, ios, qq; only effective for h2/grpc (the QUIC family does TLS inside quic-go and cannot be injected)
masque_alpn client empty (auto) MASQUE carrier: h3 (QUIC only), h2 (TCP extended CONNECT only), empty = auto (h3 first; pins h2 when UDP is unreachable)
padding.min_record_bytes shared 0 (off) minimum application-layer tunnel record length; must be 17..65527. The client shapes the uplink, the server shapes the downlink
padding.max_record_bytes shared 125% of the minimum random cap for application-layer tunnel records; at most 65535, at least 8B above the minimum. Large UDP packets are never split to satisfy the cap
pprof server empty when non-empty, start net/http/pprof at that address (e.g. 127.0.0.1:6060); bind only to trusted addresses
heartbeat_sec client 25 CDN bidirectional heartbeat; negative disables it
session_window_kb shared 256 bounded ring window per resumable session
handshake_ack_ms client 3000 data-plane handshake ack timeout
keepalive_sec client 15 session/backup-line keepalive interval
standby_connections client 0 number of hot standby connections
drain_timeout_sec shared 30 seconds to wait for existing sessions at exit
log_level shared info debug, info, warn, error

Every field can be overridden by an uppercased env var of the same name, e.g. H2TUNNEL_SERVER, H2TUNNEL_TRANSPORT, H2TUNNEL_STANDBY_CONNECTIONS, H2TUNNEL_UTLS, H2TUNNEL_MASQUE_ALPN, H2TUNNEL_PADDING_MIN_RECORD_BYTES, H2TUNNEL_PADDING_MAX_RECORD_BYTES, H2TUNNEL_PPROF. Malformed boolean or integer env values also fail at startup.

MASQUE dual carriers (h3 / h2)

transport: masque describes the protocol shape (CONNECT + .well-known/masque/... URI + the resume/2 data plane); the carrier is selectable:

  • h3: QUIC/UDP, ALPN h3.
  • h2: TCP/TLS over HTTP/2 extended CONNECT (RFC 8441, :protocol pseudo-header).
  • auto (default): h3 first; the first failed h3 dial pins h2 (links with UDP blocked need not wait for the QUIC timeout again per connection). Use masque_alpn to force one.

Server-side listeners are automatic for both carriers: masque makes TCP and QUIC optional stacks (listenerPlan), and ListenAndServe opens both by default. ⚠️ For the server to accept extended CONNECT over h2, the process must set GODEBUG=http2xconnect=1 at startup (x/net reads that switch only once in init, and //go:debug rejects non-stdlib keys). Without it the h3 carrier is unaffected and only the h2 leg is explicitly rejected with extended connect not supported by peer — the CLI logs a WARN when masque is enabled and the switch is missing. This limitation disappears if x/net upstream drops the gate.

Multi-protocol server

The server's transport is an allow-list, for example:

{
  "mode": "server",
  "listen": ":8443",
  "transport": "h2,h3,masque",
  "network": "all",
  "token": "replace-with-a-long-random-token"
}

TCP-based TLS and QUIC-based protocols can share one numeric port. h2c is plaintext and cannot mix with TLS/H3 protocols on the same listen address. Each client process selects exactly one transport. Note h2 is now a TLS-only transport: use h2c for a plaintext origin.

Generating auxiliary configs
h2tunnel gen-nginx -domain tunnel.example.com -path /tunnel -backend 127.0.0.1:8443
h2tunnel gen-systemd -bin /usr/local/bin/h2tunnel -listen :8443 -path /tunnel -token 'TOKEN'
h2tunnel gen-uri -host tunnel.example.com -port 443 -path /tunnel -token 'TOKEN'

Performance and reliability

  • Transport/network dispatch on hot paths is compiled into bitmasks at server start; request handling never re-parses config strings.
  • Recovery windows are strictly bounded and never grow with connection lifetime.
  • TCP and UDP use independent primary lines so datagram bursts cannot stall byte streams; enable standby_connections only when fast failover matters.
  • TLS configs are cloned at client and server construction, so callers can safely reuse their own templates.
  • UDP writes use bounded queues and direct selection instead of one goroutine per datagram.
  • CDN non-2xx responses never commit unacknowledged data; after service recovery it continues from the server-confirmed cursor.

If you prefer lower memory, lower session_window_kb; if the link is flaky or throughput is high, raise it. When unacknowledged data during recovery exceeds the window it fails explicitly instead of silently dropping or reordering.

Testing and verification

go test ./...
go vet ./...
go build ./cmd/h2tunnel

Tests cover out-of-package API compilation and real TCP/UDP end-to-end transport, auth failures, target denial, context cancellation propagation, CDN latency/error/buffering behavior, recovery handshakes, concurrent closes, and steady-state benchmarks. TestProtocolRealTargetMatrix validates every protocol against real semantic targets: the TCP target is a real HTTP server (10 keep-alive round-trips over one tunnel connection, asserting zero new connections on the target side) and the UDP target is a real DNS server (10 independent A queries over one PacketConn, validated packet by packet).

# All-protocol × TCP/UDP throughput benchmarks (loopback)
go test -run '^$' -bench '^BenchmarkProtocolThroughput$' -benchmem .

# Data-plane microbenchmarks (frame codec / ring / session downlink / uplink isolation)
go test -run '^$' -bench 'BenchmarkWriteFrame32KB|BenchmarkReadFrame32KB|BenchmarkRingAppendOverwrite32KB|BenchmarkRingReadAt32KB|BenchmarkSessionDownlinkWrite|BenchmarkTunnelSessionUplinkUnderDownlink' -benchmem .

# CDN-topology end-to-end benchmark
go test -run '^$' -bench '^BenchmarkPublicAPIThroughCDN72KB$' -benchmem .

Continuous integration and releases

Every push automatically runs go vet, go build, and go test -race on Ubuntu, Windows, and macOS (see .github/workflows/test.yml).

The release flow (.github/workflows/release.yml):

  1. Automatic release: push any v* tag to trigger; cross-compiles 7 platforms of bare binaries (linux amd64/arm64/arm/386, windows amd64, darwin amd64/arm64), uploaded directly as Release assets without packaging archives.
  2. Manual build: trigger the Release workflow manually from the GitHub Actions page; artifacts are only collected into that run's Artifacts (h2tunnel-manual-<sha>) and no Release is created.

Binaries inject the version via -ldflags "-X github.com/NNdroid/h2tunnel.buildVersion=...", formatted as v1.0.yyyyMMdd-<short commit hash>, which h2tunnel version prints. The tag name itself does not enter version computation; after pushing, the Release title is the computed canonical version.

Security notes

  • Both Authenticator and TargetDialer are mandatory in the package API.
  • Pre-shared tokens should be high-entropy random values and always used with TLS.
  • TLSConfig.InsecureSkipVerify is only for controlled test environments.
  • The server should only expose the networks and transports you need; prefer logical service registries over dialing arbitrary addresses.
  • Principal.ID must be stable; resumed sessions bind identity, target, and network and must not be reattached by a different identity.
  • /healthz returns an uncacheable simple health status; other unknown paths return 404.

Documentation

Overview

Package h2tunnel provides an embeddable, authenticated, resumable tunnel over HTTP/2, HTTP/3, WebTransport, MASQUE, and gRPC transports.

A Client acts as a concurrent net.Conn dialer. A Server authenticates every request and delegates target authorization and connection establishment to a mandatory TargetDialer. NewClient and NewServer perform no network I/O.

Minimal end-to-end setup (development TLS, closed-by-default service registry):

tlsConfig, _ := h2tunnel.SelfSignedTLSConfig("localhost")
auth, _ := h2tunnel.NewTokenAuthenticator("long-random-token")
dialer, _ := h2tunnel.NewStaticServiceDialer(map[string]h2tunnel.Service{
	"ssh": {Network: h2tunnel.NetworkTCP, Address: "127.0.0.1:22"},
}, nil)
server, _ := h2tunnel.NewServer(h2tunnel.ServerOptions{
	Transports:    []h2tunnel.Transport{h2tunnel.TransportH2},
	TLSConfig:     tlsConfig,
	Authenticator: auth,
	Dialer:        dialer,
})
go server.ListenAndServe(":8443")

client, _ := h2tunnel.NewClient(h2tunnel.ClientOptions{
	Endpoint:    "https://localhost:8443",
	Credentials: credentials,                           // h2tunnel.NewTokenCredentials(...)
	TLSConfig:   &tls.Config{InsecureSkipVerify: true}, // self-signed dev cert
})
conn, _ := client.DialContext(ctx, h2tunnel.NetworkTCP, "ssh")
// conn is a net.Conn over the tunnel; use it like any TCP connection.

Dial errors match the exported sentinels: errors.Is(err, h2tunnel.ErrUnauthenticated) means the token was rejected (HTTP 407/401) and errors.Is(err, h2tunnel.ErrForbidden) means the target was denied (HTTP 403). See the README for production deployment guidance (CDN topology, token hygiene, and logical service registries).

Index

Examples

Constants

View Source
const (
	NetworkTCP = "tcp"
	NetworkUDP = "udp"
)
View Source
const (
	// TunnelDeathMaxRetries: redials exhausted, session abandoned.
	TunnelDeathMaxRetries = "max retries"
	// TunnelDeathAuthRejected: server authentication rejected (407/401).
	TunnelDeathAuthRejected = "auth rejected"
	// TunnelDeathPeerFIN: peer ended normally (EOF/END frame).
	TunnelDeathPeerFIN = "peer FIN"
	// TunnelDeathDataGap: the downlink seq gap is unrecoverable (the server-side
	// session may have been reclaimed by idle timeout or its window overwritten).
	TunnelDeathDataGap = "data gap"
	// TunnelDeathCanceled: the caller's context was canceled or the Client closed.
	TunnelDeathCanceled = "canceled"
)

Client tunnel death reasons (common values of ClientEvent.Reason).

Variables

View Source
var (
	ErrUnauthenticated      = errors.New("h2tunnel: unauthenticated")
	ErrForbidden            = errors.New("h2tunnel: target forbidden")
	ErrUnsupportedNetwork   = errors.New("h2tunnel: unsupported network")
	ErrUnsupportedTransport = errors.New("h2tunnel: unsupported transport")
)

Functions

func PprofHandler

func PprofHandler() http.Handler

PprofHandler returns a handler exposing the standard net/http/pprof endpoints (Go runtime CPU/heap/goroutine/block/mutex profiles). Zero configuration: it is safe to reuse with no arguments.

Note: these endpoints can dump process memory and goroutine stacks — expose them only on a trusted network surface (an admin port, a localhost reverse proxy, or behind authentication).

func SelfSignedTLSConfig

func SelfSignedTLSConfig(host string) (*tls.Config, error)

SelfSignedTLSConfig returns a TLS config carrying a freshly generated self-signed certificate. The certificate is valid for host (default "localhost") and always for 127.0.0.1 / ::1, so clients can verify the connection when dialing loopback with a normal TLS config. Intended for development and protected origin links — use a publicly trusted certificate in production.

func Version

func Version() string

Version returns the build version embedded by the release workflow.

Types

type Authenticator

type Authenticator func(context.Context, *http.Request) (Principal, error)

Authenticator authenticates an incoming tunnel request. Implementations must treat request headers as read-only and return a stable, non-empty Principal.ID.

func NewTokenAuthenticator

func NewTokenAuthenticator(token string) (Authenticator, error)

NewTokenAuthenticator creates a constant-time pre-shared-token authenticator. Both the canonical Authorization header and the CDN-safe X-Auth-Token header are accepted.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is an embeddable tunnel dialer. A Client may be used concurrently.

Example

ExampleClient demonstrates embedding the client and dialing a logical service through the tunnel.

package main

import ()

func main() {
	// credentials, _ := h2tunnel.NewTokenCredentials("long-random-token")
	// client, _ := h2tunnel.NewClient(h2tunnel.ClientOptions{
	// 	Endpoint:    "https://tunnel.example.com",
	// 	Credentials: credentials,
	// })
	// _ = client.Start(context.Background())
	// conn, err := client.DialContext(context.Background(), h2tunnel.NetworkTCP, "ssh")
	// if err != nil {
	// 	if errors.Is(err, h2tunnel.ErrUnauthenticated) {
	// 		// token rejected → switch to another token
	// 	} else if errors.Is(err, h2tunnel.ErrForbidden) {
	// 		// target rejected → change target or request authorization
	// 	}
	// }
	// _ = conn
}

func NewClient

func NewClient(options ClientOptions) (*Client, error)

NewClient validates options without performing network I/O.

func (*Client) Close

func (c *Client) Close() error

Close immediately closes all active tunnels and transport pools.

func (*Client) DialContext

func (c *Client) DialContext(ctx context.Context, network, target string) (net.Conn, error)

DialContext establishes a TCP tunnel and returns only after the remote target and resume/2 handshake are ready.

network accepts "", "tcp", "tcp4", and "tcp6"; the address family only governs the local side of the API contract — the server dials the target with its own TargetDialer and may resolve it differently.

Dial errors match the exported sentinels via errors.Is: ErrUnauthenticated (HTTP 407/401, token rejected) and ErrForbidden (HTTP 403, target denied by policy).

func (*Client) DialPacketContext

func (c *Client) DialPacketContext(ctx context.Context, network, target string) (PacketConn, error)

DialPacketContext establishes a connected UDP tunnel.

func (*Client) ForceReconnect

func (c *Client) ForceReconnect()

ForceReconnect forces every active tunnel to abandon its current stream and redial immediately (session id and recovery window are preserved; the peer is unaware). Embedders call it proactively when a network-change event (NotifyAddrChange / NWPathMonitor / ConnectivityManager) arrives, skipping the passive latency of waiting for a heartbeat timeout. Whether a session actually terminates is still governed by AutoRedial / the redial cap.

func (*Client) SetEventHandler

func (c *Client) SetEventHandler(h ClientEventHandler)

SetEventHandler registers the client event callback (replacing the previous one; passing nil stops dispatch). The callback runs on its own goroutine and panics are recovered, so it never affects the tunnel engine — but it should return quickly. See the dispatch constraints in events.go.

func (*Client) Shutdown

func (c *Client) Shutdown(ctx context.Context) error

Shutdown rejects new dials and waits for active tunnels to close naturally. When ctx expires the remaining tunnels keep draining in the background; call Close to force-close them (the internal waiter terminates once Close runs, since Close tears down every active tunnel).

func (*Client) Start

func (c *Client) Start(ctx context.Context) error

Start initializes and verifies the transport. It is safe to call concurrently and is also invoked lazily by DialContext/DialPacketContext. A failed Start releases all transport resources and returns the error; the Client may be retried (state resets so Start can run again).

func (*Client) Stats

func (c *Client) Stats() *ClientStats
Example

ExampleClient_Stats demonstrates collecting tunnel stats (can be pushed to a monitoring system periodically).

package main

import ()

func main() {
	// var client *h2tunnel.Client
	// stats := client.Stats()
	// promActiveTunnels.Set(float64(stats.ActiveDials.Load()))
	// promUplinkBytes.Add(float64(stats.UplinkBytes.Load()))
}

type ClientDialer

type ClientDialer func(context.Context, string, string) (net.Conn, error)

ClientDialer and QUICDialer let embedding applications control the underlying sockets (for example interface binding or Android VPN protect). Nil values keep the standard library / quic-go dialers.

type ClientEvent

type ClientEvent struct {
	Kind      ClientEventKind
	Target    string  // logical target at dial time
	Network   Network // tcp / udp
	Transport Transport
	// Attempt is meaningful only for Reconnecting events: the upcoming redial
	// ordinal (1-based).
	Attempt int
	// Reason is a human-readable cause: for TunnelDied see the TunnelDeath*
	// constants; for Reconnecting it is the underlying error text; empty otherwise.
	Reason string
	// Err is the underlying error (may be nil).
	Err error
}

ClientEvent is the client event payload.

type ClientEventHandler

type ClientEventHandler func(ClientEvent)

ClientEventHandler is the client event callback. It is invoked on its own goroutine; panics are recovered and never affect the tunnel engine. But the callback should return quickly — if you need blocking work (writing to a metrics queue, etc.), make it asynchronous yourself.

type ClientEventKind

type ClientEventKind string

ClientEventKind identifies a client event type.

const (
	// EventTunnelEstablished: the tunnel is ready (handshake done, target dialed).
	EventTunnelEstablished ClientEventKind = "tunnel_established"
	// EventTunnelDied: the tunnel died. Reason explains why (see the
	// TunnelDeath* constants and the ClientEvent.Reason docs).
	EventTunnelDied ClientEventKind = "tunnel_died"
	// EventReconnecting: after a stream break, redial the same session (resumable).
	EventReconnecting ClientEventKind = "reconnecting"
	// EventTargetDenied: the server denied the target with 403 (policy/registry lacks the service).
	EventTargetDenied ClientEventKind = "target_denied"
)

type ClientOptions

type ClientOptions struct {
	// Server address (with scheme). https pairs with h2/h3/wt/masque, http with
	// h2c; when Transport is empty it is inferred from the scheme.
	Endpoint string
	// Tunnel HTTP path (default "/"). MASQUE endpoints are nested under it:
	// <path>.well-known/masque/{tcp,udp}/<host>/<port>/.
	Path string
	// Transport protocol; empty = inferred from the Endpoint scheme (https→h2, http→h2c).
	Transport Transport
	// Overrides the HTTP Host header (CDN multi-tenant origin-fetch scenarios).
	Host string
	// TLS config; used after a Clone so callers can safely reuse theirs. Not
	// allowed with an http endpoint.
	TLSConfig *tls.Config
	// UtlxFingerprint enables utls fingerprint disguise: rewrites the TLS
	// ClientHello into a real browser's shape (chrome/firefox/edge/safari/ios/qq)
	// to resist JA3/JA4-based TLS fingerprinting. Empty = Go native crypto/tls.
	// Only applies to h2/grpc (TLS over TCP): the TLS for h3/wt/masque is done
	// inside quic-go and cannot be injected, so configuring it there errors in
	// NewClient; the http endpoint (h2c) likewise.
	UtlxFingerprint string
	// Per-request authentication callback (typically from NewTokenCredentials).
	Credentials CredentialProvider
	Tuning      ClientTuning
	// Event callback (optional, injected at construction; or SetEventHandler at runtime).
	EventHandler ClientEventHandler
	Logger       *slog.Logger
	// Underlying TCP socket dialer (interface binding / VPN protect); nil = stdlib.
	Dialer ClientDialer
	// Underlying QUIC dialer (for h3/wt/masque); nil = quic-go default.
	QUICDialer QUICDialer
}

ClientOptions configures an embeddable tunnel client. It intentionally has no local listen address or default target: callers pass the target per dial. ClientEventHandler is an optional client event callback (TunnelEstablished, TunnelDied, Reconnecting, TargetDenied), dispatched on its own goroutine with panics recovered. It can also be registered at runtime via Client.SetEventHandler.

type ClientStats

type ClientStats struct {
	// DialAttempts: number of dials initiated (TCP DialContext + UDP DialPacketContext).
	DialAttempts atomic.Int64
	// DialFailures: number of failed dials (handshake not ready, server rejected, etc.).
	DialFailures atomic.Int64
	// UplinkBytes: bytes the client sent (uplink through the tunnel).
	UplinkBytes atomic.Int64
	// DownlinkBytes: bytes the client received (downlink through the tunnel).
	DownlinkBytes atomic.Int64
	// ResumeReconnects: number of TCP/UDP session reconnects (same-session redial).
	ResumeReconnects atomic.Int64
	// ActiveDials: number of dials currently in progress.
	ActiveDials atomic.Int64
}

ClientStats holds cumulative client statistics.

type ClientTuning

type ClientTuning struct {
	// Session-recovery ring window size (bytes). 0 = default 256KB, capped at
	// 64MB (larger errors). Outage-recovery note: outage duration × downlink rate
	// > window ⇒ the gap is unrecoverable and the session terminates. Raise it for
	// long outages or high throughput.
	SessionWindowBytes int
	// CDN two-way heartbeat interval. 0 = default 25s; negative = disable the
	// heartbeat entirely (only for direct origin links, no CDN/reverse proxy in
	// between); positive values are clamped to [5s, 5min].
	HeartbeatInterval time.Duration
	// Backup-lane KEEPALIVE interval. 0 = default 15s; valid range 1s–1h, larger errors.
	KeepaliveInterval time.Duration
	// Data-plane handshake HANDSHAKE-ACK timeout. 0 = default 3s; valid range
	// 1ms–30s, larger errors.
	HandshakeTimeout time.Duration
	// Number of hot standby connections (0 = disabled; unsupported by WT, must be 0).
	StandbyConnections int
	// UDP datagram uplink queue depth. 0 = default 200; valid range 0–65536,
	// larger errors. When full, writes block until the write deadline (CLI drops);
	// raising it absorbs bursts at the cost of memory.
	DatagramQueueSize int
	// Network-change self-heal: after redials exhaust (16), reset the retry
	// counter and keep dialing (infinite revival), fitting mobile networks where
	// "disconnected, waiting for the network to return"; false = terminate on
	// exhaustion and dispatch a TunnelDied(max retries) event for the caller to
	// decide whether to redial. Default false.
	AutoRedial bool
	// Per-attempt dial budget. 0 = unlimited (rely on the transport timeout); a
	// positive value bounds only each attempt's connect + handshake phase (the
	// timer stops once the tunnel is ready, so established streams are not
	// bounded), used to tighten the give-up cadence during an outage (e.g. 10s,
	// so 16 redials exhaust in ~3 minutes).
	RedialBudget time.Duration
	// Padding controls application-layer tunnel record shaping. Padding is
	// removed by the peer and is never forwarded to the TCP or UDP target.
	// The zero value disables padding.
	Padding PaddingTuning
	// MASQUE carrier selection (only valid with Transport=masque): "h3" = QUIC/UDP
	// only; "h2" = TCP extended CONNECT only (the server must enable extended
	// CONNECT, see the ClientTuning docs / README GODEBUG note); empty = automatic
	// (h3 first, automatically pinned to h2 if the h3 dial fails). On UDP-blocked
	// deployments, explicitly setting "h2" skips the first-connection QUIC handshake timeout.
	MasqueALPN string
}

ClientTuning contains the small set of knobs that materially affect CDN reliability or per-session memory. Zero values select safe defaults.

type CredentialProvider

type CredentialProvider func(context.Context, http.Header) error

CredentialProvider adds authentication data to one outgoing tunnel request. Protocol-owned headers are restored after this callback returns and therefore cannot be overridden by a credential provider.

func NewTokenCredentials

func NewTokenCredentials(token string) (CredentialProvider, error)

NewTokenCredentials creates CDN-safe client token credentials.

type DialKind

type DialKind string

DialKind classifies why the server is dialing a target.

const (
	// DialKindBusiness is a client-initiated tunnel to a real target.
	DialKindBusiness DialKind = "business"
	// DialKindProbe is a keep-alive lane handshake; see DialRequest.Kind.
	DialKindProbe DialKind = "probe"
)

type DialRequest

type DialRequest struct {
	Network   Network
	Target    string
	Transport Transport
	Principal Principal
	// Kind distinguishes why a dial happens. DialKindProbe is the handshake of a
	// probe/warm-up lane: the server never establishes a real connection for it
	// (probe lanes never dial); Target is only for authorization logging.
	// Business tunnels are always DialKindBusiness.
	Kind DialKind
}

DialRequest is passed to the server's policy-aware target dialer.

type Listeners

type Listeners struct {
	TCP  net.Listener
	QUIC net.PacketConn
}

Listeners groups the stream and QUIC listeners owned by Server.Serve.

type Network

type Network string

Network identifies the application network transported through the tunnel.

type PacketConn

type PacketConn interface {
	net.Conn
	net.PacketConn
}

PacketConn is a connected datagram tunnel that can be consumed as either a net.Conn or net.PacketConn.

type PaddingTuning

type PaddingTuning struct {
	// MinRecordBytes enables padding when greater than zero. It must be larger
	// than the 16-byte resume record header and leave room for the random range.
	MinRecordBytes int `json:"min_record_bytes"`
	// MaxRecordBytes is the inclusive random upper bound. Zero derives a
	// default 25% above MinRecordBytes. It must not exceed 65535.
	MaxRecordBytes int `json:"max_record_bytes"`
}

PaddingTuning controls application-layer record shaping. When enabled, each stream record is randomly sized between MinRecordBytes and MaxRecordBytes. Datagram boundaries are preserved: small datagrams are padded, while a datagram already larger than MaxRecordBytes is sent as one intact record.

This does not promise a minimum IP packet size. TLS, HTTP/2, HTTP/3, QUIC, TCP segmentation, acknowledgements, retransmissions, CDNs, and the path MTU may split or coalesce application records after h2tunnel writes them.

type Principal

type Principal struct {
	ID    string
	Roles []string
}

Principal is the authenticated identity bound to a resumable session.

type QUICDialer

type QUICDialer func(context.Context, string, *tls.Config, *quic.Config) (*quic.Conn, error)

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is an embeddable, single-lifecycle tunnel server.

Example

ExampleServer demonstrates embedding a tunnel server with a closed service registry.

package main

import ()

func main() {
	// tlsConfig, _ := h2tunnel.SelfSignedTLSConfig("localhost")
	// auth, _ := h2tunnel.NewTokenAuthenticator("long-random-token")
	// dialer, _ := h2tunnel.NewStaticServiceDialer(map[string]h2tunnel.Service{
	// 	"ssh": {Network: h2tunnel.NetworkTCP, Address: "127.0.0.1:22"},
	// }, nil)
	// server, _ := h2tunnel.NewServer(h2tunnel.ServerOptions{
	// 	Transports:    []h2tunnel.Transport{h2tunnel.TransportH2},
	// 	TLSConfig:     tlsConfig,
	// 	Authenticator: auth,
	// 	Dialer:        dialer,
	// })
	// _ = server.ListenAndServe(":8443")
}

func NewServer

func NewServer(options ServerOptions) (*Server, error)

NewServer validates options and creates a server without opening sockets or starting goroutines.

func (*Server) Close

func (s *Server) Close() error

Close immediately terminates listeners, target connections, and sessions.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the tunnel handler for embedding in an existing HTTP server.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(address string) error

ListenAndServe opens the listeners required by the configured transports. TCP and QUIC use the same numeric port, including when address uses port 0.

func (*Server) Listeners

func (s *Server) Listeners() Listeners

Listeners returns the listeners currently bound by Serve. Before Serve is called both members are nil; the QUIC member can report its actual port via LocalAddr() when port 0 is used (a WT-only server has no TCP listener, so this is the only way to discover the port). The return value is an internal reference for reading addresses only; callers must not close the listeners (ownership belongs to Serve).

func (*Server) Serve

func (s *Server) Serve(listeners Listeners) error

Serve owns the supplied listeners and blocks until all enabled transports stop. A failure in one listener stops its sibling before Serve returns.

func (*Server) SetEventHandler

func (s *Server) SetEventHandler(h ServerEventHandler)

SetEventHandler registers the server event callback (replacing the previous one; passing nil stops dispatch).

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown stops new requests and waits for owned HTTP/QUIC servers to drain.

func (*Server) Stats

func (s *Server) Stats() *ServerStats

type ServerEvent

type ServerEvent struct {
	Kind       ServerEventKind
	SessionID  string
	Target     string  // logical target; empty for AuthRejected events
	Network    Network // tcp / udp
	Transport  Transport
	Principal  Principal // request identity on successful auth; zero value for AuthRejected
	RemoteAddr string    // client source IP (clientIP fallback chain, spoofable, for logging only)
	Reason     string
	Err        error
}

ServerEvent is the server event payload.

type ServerEventHandler

type ServerEventHandler func(ServerEvent)

ServerEventHandler is the server event callback. Invocation semantics match ClientEventHandler.

type ServerEventKind

type ServerEventKind string

ServerEventKind identifies a server event type.

const (
	// ServerEventSessionOpened: a new session was established (first join of a new session id).
	ServerEventSessionOpened ServerEventKind = "session_opened"
	// ServerEventSessionResumed: an existing session reconnected and resumed.
	ServerEventSessionResumed ServerEventKind = "session_resumed"
	// ServerEventSessionClosed: the session closed (peer END, idle reclaim, or server shutdown).
	ServerEventSessionClosed ServerEventKind = "session_closed"
	// ServerEventAuthRejected: authentication failed (valuable to SIEM: possible credential brute-force).
	ServerEventAuthRejected ServerEventKind = "auth_rejected"
	// ServerEventTargetDenied: the target was denied by policy (403 / unsupported network).
	ServerEventTargetDenied ServerEventKind = "target_denied"
	// ServerEventReplayDropped: the downlink replay gap is unrecoverable (the
	// replay window was overwritten; the client was disconnected too long); the
	// session's downlink coordinate is broken and the client will reopen it.
	ServerEventReplayDropped ServerEventKind = "replay_dropped"
)

type ServerOptions

type ServerOptions struct {
	EventHandler ServerEventHandler
	// Tunnel HTTP path (default "/"). MASQUE endpoints are nested under it:
	// <path>.well-known/masque/{tcp,udp}/<host>/<port>/.
	Path          string
	Transports    []Transport
	Networks      []Network
	TLSConfig     *tls.Config
	Authenticator Authenticator
	Dialer        TargetDialer
	Tuning        ServerTuning
	Logger        *slog.Logger
}

ServerOptions configures an embeddable tunnel server. Authenticator and Dialer are mandatory so a library server never becomes an open proxy by accident. ServerEventHandler is an optional server event callback (Session*, AuthRejected, TargetDenied, ReplayDropped), dispatched on its own goroutine with panics recovered. It can also be registered at runtime via Server.SetEventHandler.

type ServerStats

type ServerStats struct {
	// SessionsCreated: number of tunnel sessions established (new session id).
	SessionsCreated atomic.Int64
	// SessionsResumed: number of session resumptions (an existing session id rejoined).
	SessionsResumed atomic.Int64
	// SessionsActive: number of currently active sessions (approximate at read time).
	SessionsActive atomic.Int64
	// UplinkBytes: bytes the server received from clients (written to target).
	UplinkBytes atomic.Int64
	// DownlinkBytes: bytes the server sent to clients (from target).
	DownlinkBytes atomic.Int64
	// AuthFailures: number of failed-authentication requests.
	AuthFailures atomic.Int64
}

ServerStats holds cumulative server statistics.

type ServerTuning

type ServerTuning struct {
	// Session-recovery ring window size (bytes). 0 = default 256KB, capped at
	// 64MB (larger errors).
	SessionWindowBytes int
	// Session idle reclaim time. 0 = default 60s (a session with no active stream
	// closes after this timeout).
	SessionIdleTimeout time.Duration
	// Padding controls server-to-client application-layer tunnel records.
	// Configure both client and server to shape both traffic directions.
	Padding PaddingTuning
}

Server performance tuning. Zero values select safe defaults.

type Service

type Service struct {
	Network Network
	Address string
	Roles   []string
}

Service describes one target in a static logical-service registry.

type TargetDialer

type TargetDialer func(context.Context, DialRequest) (net.Conn, error)

TargetDialer authorizes, resolves, and connects one requested target. For UDP it must return a connected datagram net.Conn (normally *net.UDPConn).

Probe/warm-up lanes (DialKindProbe) only appear in authorization logs; the server never calls Dialer for them — an implementation can skip establishing connections for probe-kind requests.

func NewStaticServiceDialer

func NewStaticServiceDialer(services map[string]Service, base *net.Dialer) (TargetDialer, error)

NewStaticServiceDialer creates a closed-by-default logical service registry. The input map and role slices are copied. Unknown services and role failures return ErrForbidden without revealing whether a service exists.

type Transport

type Transport string

Transport identifies the HTTP transport carrying a tunnel stream.

const (
	TransportAuto         Transport = ""
	TransportH2           Transport = "h2"
	TransportH2C          Transport = "h2c"
	TransportH3           Transport = "h3"
	TransportWebTransport Transport = "wt"
	TransportMASQUE       Transport = "masque"
	TransportGRPC         Transport = "grpc"
)

type TunnelError

type TunnelError struct {
	// contains filtered or unexported fields
}

TunnelError means the server rejected tunnel establishment with an HTTP status code. Beyond the public sentinels, embedders can use errors.As(*TunnelError) to recover the original status for fine-grained handling.

func (*TunnelError) Error

func (e *TunnelError) Error() string

func (*TunnelError) HTTPStatus

func (e *TunnelError) HTTPStatus() int

HTTPStatus returns the HTTP status the server rejected with.

func (*TunnelError) Unwrap

func (e *TunnelError) Unwrap() error

Directories

Path Synopsis
cmd
h2tunnel command
internal

Jump to

Keyboard shortcuts

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