webrelay

package
v1.2.1 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: MIT Imports: 28 Imported by: 0

Documentation

Overview

Package webrelay is the server side of the WEB proxy v1 transport a Telegram client reaches over HTTPS. This file holds the bridge capability: the value a client derives from its own hostname and secret, and the only thing a request presents to pick one configured profile.

The three recognition tiers

Recognition is a security invariant, not a routing convenience, and a handler must place every request into exactly one of three tiers:

  • Exact. An exact GET / carrying one canonical CapabilityLength parameter that Registry.Lookup resolves. Serve the bridge page.
  • Authentic but unusable. Registry.IsAuthentic says the value is a spelling of a configured capability, but something else disqualifies it: a padded or standard-alphabet spelling, a set unused trailing bit, the wrong path, the wrong method, a duplicated query field. Answer LOCALLY with an uncacheable 404. Never proxy such a request to the public site -- that would write an operator's real capability into a third party's access log, which is the leak this tier exists to prevent.
  • Unauthentic. Everything else. Serve the ordinary cover site, unaltered, so the origin looks like the website it claims to be.

Two obligations the registry cannot enforce for the caller:

  • Percent decoding is the caller's job. Both entry points compare the string they are given, so pass the decoded value -- and decode it with net/url.PathUnescape or off RawQuery, not with net/url.Values or net/url.QueryUnescape. Those two turn the standard base64 alphabet's "+" into a space, which is exactly one of the tier-two disguises. ParseCapabilityLenient undoes that mangling anyway, but only because the leak it would otherwise cause is unrecoverable.
  • A repeated query field must be tested in full. Test EVERY value the request carries under the bridge key, not the one net/url happens to return first, or a duplicate field smuggles a capability past tier two.

Index

Constants

View Source
const (
	// QueueItemCharge is the flat per-item allowance added to every queued
	// frame's encoded size.
	QueueItemCharge = 256

	// DefaultPendingBytes and DefaultPendingItems bound one session's downlink
	// queue.
	DefaultPendingBytes int64 = 32 << 20
	DefaultPendingItems int64 = 16384

	// DefaultGlobalPendingBytes and DefaultGlobalPendingItems bound every
	// session in the process together.
	DefaultGlobalPendingBytes int64 = 512 << 20
	DefaultGlobalPendingItems int64 = 262144

	// ControlReserveItems is the item headroom held back from bulk DATA so that
	// WINDOW, CLOSE and session frames can always be queued. Without it a
	// saturated downlink would block the very frames that unblock it: the relay
	// would stop being able to grant credit precisely when the peer is waiting
	// for credit.
	ControlReserveItems int64 = 256

	// ControlReserveBytes is the same reserve counted in bytes. Control frames
	// are at most twelve bytes plus the item charge, so this is generous.
	ControlReserveBytes int64 = ControlReserveItems * (QueueItemCharge + 16)
)

Pending accounting -- memory admission control, separate from and additional to the per-stream window.

The unit of charge is the encoded size plus a flat allowance for every queued item, because the byte figure alone is a poor proxy for what a queue costs: a WINDOW frame is twelve bytes on the wire and a whole allocation, a slice header and a scheduling decision in this process. The item budget is what stops a flood of tiny frames exhausting memory while staying far below the byte budget.

View Source
const (
	// MaxTombstones is how many recently closed stream ids a session remembers.
	// They exist for one reason: a CLOSE and the peer's last DATA cross on the
	// wire constantly, and without tombstones that ordinary race would look
	// like a frame for a stream that never existed.
	MaxTombstones = 4096

	// DefaultMaxStreams bounds the logical connections one session may hold
	// open. A WEB client uses several times the connections of a direct one, so
	// this is generous; it is a ceiling against a client gone wrong, not a
	// quota.
	DefaultMaxStreams = 512

	// DefaultDialTimeout bounds one backend dial.
	DefaultDialTimeout = 5 * time.Second
)

Session-level bounds and errors.

View Source
const (
	// BootstrapTTL is how long the token embedded in one bridge page stays
	// usable. It is short because the page is loaded and used immediately: the
	// only gap it has to cover is the client's own WebView startup.
	BootstrapTTL = 2 * time.Minute

	// ReconnectGrace is how long a session with no carrier attached stays
	// resumable. An alive bridge refreshes it; a client that has gone entirely
	// silent loses the session, and the desktop MTProto layer has already reset
	// its own connections well before this expires.
	ReconnectGrace = 2 * time.Minute

	// DefaultMaxSessions bounds concurrent carrier sessions in one process.
	DefaultMaxSessions = 1024
)

Lifetimes. Every one of these is the published default; none is negotiated with the client, which learns nothing from WELCOME.

View Source
const (

	// TokenLength is the number of characters a token occupies in a header.
	TokenLength = 43

	// TokenKeySize is the length of the signing key on disk.
	TokenKeySize = 32
)
  • What the MAC is for, and what it is not for

Token layout. A token is 32 opaque bytes -- a 16-byte nonce and 16 bytes of MAC over it -- carried as 43 characters of unpadded base64url, the same spelling and length as a bridge capability.

What the MAC is for, and what it is not for

It establishes provenance only: that this process, or a previous run of it holding the same key, minted the value. It authorises nothing. Session state, bootstrap expiry, single use, the request method and the request path are all still checked afterwards, and a token that passes the MAC and fails any of them is refused.

The reason to have it at all is the origin's three-tier rule. A request carrying a token this relay minted must never be handed to the cover site, even when the token is expired, spent, or presented on the wrong path -- otherwise an operator's real credential lands in a third party's access log. Recognising one after a restart, or after the session it belonged to is gone, needs a key that outlives both. That is the whole job.

View Source
const CapabilityLength = 43

CapabilityLength is the number of characters a derived capability occupies in a bridge query: 32 bytes of HMAC-SHA256 in unpadded base64url.

View Source
const InitialWindow uint32 = 4 << 20

InitialWindow is the receive credit each direction of every stream starts with. It is implicit: no frame carries it, WELCOME is empty, there is no OPEN acknowledgement, and the client has the same number compiled in. A relay that starts from a different figure is not misconfigured, it is silently broken -- which is why this is a constant and not an option.

The value is deliberately twice the carrier batch, so one stream can keep sending while returned credit is still crossing the opposite direction.

View Source
const MaxProfiles = 4096

MaxProfiles bounds the configured profile set. Every request scans the whole set without breaking early -- that is what keeps the scan constant-time -- so the set length is per-request work an operator chooses, and an unbounded one would turn a config edit into a slow-loris amplifier. The ceiling is far above any real inbound: a scan of this many 32-byte comparisons is tens of microseconds.

Variables

View Source
var (
	// ErrInvalidHost means the hostname is not the canonical form both sides
	// must agree on before they derive anything.
	ErrInvalidHost = errors.New("hostname is not a canonical WEB proxy host")

	// ErrUnsupportedSecretMode means the secret cannot be handed to a WEB
	// client at all.
	ErrUnsupportedSecretMode = errors.New("secret mode is not usable for a WEB proxy")

	// ErrCapabilityCollision means two configured profiles derive the same
	// capability, so a request could not name one of them.
	ErrCapabilityCollision = errors.New("two profiles derive the same bridge capability")

	// ErrNoProfiles means the profile set is empty. A registry that can match
	// nothing answers every request with the cover site, which looks exactly
	// like a working relay whose clients all typed the wrong hostname.
	ErrNoProfiles = errors.New("no WEB proxy profiles configured")

	// ErrTooManyProfiles means the profile set is above MaxProfiles.
	ErrTooManyProfiles = errors.New("more WEB proxy profiles than the maximum")

	// ErrInvalidSecret means the secret is not a usable one -- in practice an
	// empty key, whose capability anyone could derive without knowing anything.
	ErrInvalidSecret = errors.New("secret is not valid")
)
View Source
var (
	// ErrSecondHello means a HELLO arrived on the carrier. HELLO is the session
	// creation body and appears nowhere else; a second one means the peer
	// believes it is starting a session that already exists.
	ErrSecondHello = errors.New("HELLO on an established carrier")

	// ErrUnknownStream means a frame named a stream that was never opened and
	// is not in the tombstone set.
	ErrUnknownStream = errors.New("frame for a stream that was never opened")

	// ErrStreamReused means an OPEN named an id that is live or recently
	// closed. Ids are never reused within a session.
	ErrStreamReused = errors.New("OPEN for a stream id already used in this session")
)
View Source
var (
	// ErrCreditExhausted means the peer sent DATA past the credit granted to
	// it. The specification makes this fatal rather than something to absorb: a
	// peer that miscounts credit will miscount it again, and from that point
	// the two ends disagree about how much is in flight.
	ErrCreditExhausted = errors.New("DATA beyond the granted receive window")

	// ErrWindowOverflow means granted credit would pass the 32-bit maximum.
	// Saturating instead would leave the two ends counting different totals with
	// nothing to notice it, so this is fatal too.
	ErrWindowOverflow = errors.New("WINDOW grant overflows the receive window")
)
View Source
var (
	// ErrTokenKeyMissing means the signing key file is absent. It is never
	// generated silently: an ephemeral key would work perfectly until the
	// relay restarts, at which point every token it ever minted becomes
	// unrecognisable and starts leaking to the cover site.
	ErrTokenKeyMissing = errors.New("WEB relay token key file is missing")

	// ErrTokenKeySize means the key file is not exactly TokenKeySize bytes.
	ErrTokenKeySize = errors.New("WEB relay token key file is the wrong size")

	// ErrTokenKeyPermissions means the key file is readable by group or other.
	ErrTokenKeyPermissions = errors.New("WEB relay token key file is readable by group or other")
)
View Source
var ErrNoBackendAddress = errors.New("no WEB relay backend address")

ErrNoBackendAddress means the relay was configured without a backend to dial.

View Source
var ErrNoCoverSite = errors.New("no cover site configured for the WEB relay origin")

ErrNoCoverSite means no handler was configured for unauthenticated requests. There is no safe default: serving nothing, or a built-in page, makes every deployment that skipped the decision look identical to a prober.

View Source
var ErrOutboxClosed = errors.New("session outbox is closed")

ErrOutboxClosed means the session is finished and nothing more will be sent.

Functions

func CanonicalHost

func CanonicalHost(host string) (string, error)

CanonicalHost normalises a configured hostname into the exact bytes the client hashes. Anything the client would refuse to store is an error here rather than a silently different capability, because a mismatch shows up only as every real client missing the lookup.

Note for internationalised names: the ACE (xn--) form is the only input guaranteed to round-trip identically on every client. A hand-typed Unicode host can be mapped differently by different client toolkit versions and then derive a capability this server never computes.

func GenerateTokenKey

func GenerateTokenKey(path string) error

GenerateTokenKey writes a new signing key, refusing to overwrite one. Rotating it invalidates every live session, so replacing a file is always a deliberate act rather than a side effect of running a command twice.

func NewStaticCover

func NewStaticCover(root string) (http.Handler, error)

NewStaticCover serves a directory as the cover site.

Directory listings are disabled. An index of the operator's files is both an information leak and a fingerprint -- Go's own listing markup is recognisable at a glance -- so a directory without an index page is a 404, exactly as it would be on an ordinary web server.

func NewUpstreamCover

func NewUpstreamCover(target string) (http.Handler, error)

NewUpstreamCover reverse-proxies the cover site to an application.

Method, path, raw query, headers and body all travel unchanged, because the point is that this hostname behaves exactly like the site behind it. The one thing deliberately not added is an X-Forwarded-* set: the relay is not announcing itself to the upstream, and a header that appears only on this deployment is one more thing to tell it apart by.

func WebSecret

func WebSecret(secret mtglib.Secret) mtglib.Secret

WebSecret re-expresses a secret's key in the one form a WEB client can hold. The three forms are spellings of the same 16 bytes, so a panel that mints ee secrets needs no second credential: this is the same key, told to the client as dd.

dd rather than plain, deliberately. Both are accepted by the client, but the dd form makes every stream padded-intermediate, which is the transport the ad-tag path supports -- a plain WEB client with an ad tag would silently lose its sponsored channel.

Types

type Backend

type Backend interface {
	// Dial connects to the configured backend on behalf of client, which is the
	// browser's address as this process learned it.
	Dial(ctx context.Context, client net.Addr) (net.Conn, error)
}

Backend opens the loopback connection one OPEN asks for. It is an interface so the session can be tested without a listener, and so the PROXY-protocol decision lives in one place rather than in the session's flow control.

type Capability

type Capability [sha256.Size]byte

Capability is the 32-byte value a client derives from its configured hostname and secret and presents as the bridge query parameter.

func Derive

func Derive(secret mtglib.Secret, host string) (Capability, error)

Derive computes the bridge capability for a secret on a host. The secret's bytes are the HMAC KEY in the wire form a client is given -- including the leading 0xdd of a dd secret, so dd and plain spellings of the same 16 key bytes deliberately derive different capabilities.

host is canonicalised here; pass the raw configured value.

func ParseCapability

func ParseCapability(param string) (Capability, bool)

ParseCapability decodes a bridge query parameter, accepting only the one canonical spelling of a 32-byte value. Strict decoding rejects a 43rd character whose unused low bits are set, and the re-encode comparison is the same rule stated a second way.

func ParseCapabilityLenient

func ParseCapabilityLenient(param string) (Capability, bool)

ParseCapabilityLenient decodes a bridge parameter under every base64 spelling of a 32-byte value: either alphabet, padded or not, and -- because these decoders are not Strict -- with the 43rd character's unused low bits set.

It exists to recognise an AUTHENTIC capability that arrived in a form the bridge cannot be served for, so the request can be answered locally instead of being handed upstream with a real capability in its query. It is deliberately wider than ParseCapability and must never gate serving the bridge.

func (Capability) String

func (c Capability) String() string

String renders the capability the way it travels in the query: unpadded base64url, always CapabilityLength characters.

type Origin

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

Origin is the HTTP face of the relay: one hostname, serving a website to everyone and a carrier to the clients that can prove they belong here.

The three tiers

Every request lands in exactly one of three classes, and the classification happens before any routing decision:

  1. Authentic and usable -- the carrier operation.
  2. Authentic but unusable -- expired, spent, wrong method, wrong path, a non-canonical spelling, a duplicated field. Answered LOCALLY with an uncacheable 404. Never delegated, because delegating it writes an operator's real credential into a third party's access log.
  3. Everything else -- the cover site, unaltered, on every path including the API ones, which may be genuine routes of whatever site is being fronted.

The consequence worth stating plainly: this origin must be the only gateway for its hostname. Routing some paths around it re-creates the distinguisher the three tiers exist to remove -- a prober would see one component's 404 and another's, and could tell them apart by header order and timing.

func NewOrigin

func NewOrigin(opts OriginOptions) (*Origin, error)

NewOrigin builds the origin. Every failure here is a startup failure: a relay that came up with an unusable profile set would serve the cover site to its own users, which is indistinguishable from working.

func (*Origin) Profiles

func (o *Origin) Profiles() int

Profiles reports how many profiles the origin currently serves.

func (*Origin) ServeHTTP

func (o *Origin) ServeHTTP(w http.ResponseWriter, r *http.Request)

func (*Origin) SetSecrets

func (o *Origin) SetSecrets(secrets []mtglib.Secret) error

SetSecrets replaces the profile set this origin serves, atomically.

It is the whole of a reload. Nothing else about a running relay can change: the host is baked into every capability and into the bridge page's own CSP, the token key must outlive its tokens, and the backend and cover site are what the process was built around. Those are restarts, and the caller is responsible for refusing a file that changed one of them.

A set that cannot be served is refused and the previous one stays live. The alternative -- a relay that empties its registry because someone saved a broken file -- serves the cover site to every real user, which looks exactly like censorship working.

Live sessions are deliberately untouched. A capability is spent at the front door, and what a session carries afterwards is MTProto that the proxy authenticates for itself: a secret revoked here is revoked there too, and the proxy closes those connections. Duplicating that here would be a second answer to a question already answered.

type OriginOptions

type OriginOptions struct {
	// Host is the canonical public hostname. It is the name the capability was
	// derived over, so it is fixed for the life of the process.
	Host string

	// Secrets are the profiles this relay serves, in the panel's order.
	Secrets []mtglib.Secret

	// Signer mints and recognises the bootstrap and session tokens.
	Signer *Signer

	// Cover handles every request that carries no authentic credential.
	Cover http.Handler

	// Backend opens the loopback connection each stream needs.
	Backend Backend

	// Limits are the two wire bounds a deployment may lower.
	Limits frame.Limits

	// MaxSessions bounds concurrent carrier sessions.
	MaxSessions int

	// MaxStreams bounds the streams inside one session.
	MaxStreams int

	// DialTimeout bounds one backend dial.
	DialTimeout time.Duration

	// ClientIP resolves the browser's address from a request. Returning nil
	// means the address is unknown, which is fatal for a stream but not for the
	// origin.
	ClientIP func(r *http.Request) net.Addr

	// Logger is optional.
	Logger mtglib.Logger
	// contains filtered or unexported fields
}

OriginOptions configures the public origin.

type Registry

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

Registry resolves a presented bridge parameter to one of the profiles the operator configured, without revealing through timing which of them matched.

func NewRegistry

func NewRegistry(host string, secrets []mtglib.Secret) (*Registry, error)

NewRegistry derives a capability for every profile secret against one public hostname, in the caller's order. Collisions are rejected because a request carrying that capability could not name a single profile.

Pass secrets through WebSecret first unless they are already dd or plain: Derive refuses an ee secret, because the client refuses one too.

func (*Registry) IsAuthentic

func (r *Registry) IsAuthentic(param string) bool

IsAuthentic reports whether a presented value is any spelling at all of a configured capability -- the tier-two test described in the package doc. A true answer means the request must be answered with a local uncacheable 404, never delegated to the cover site.

It returns no index on purpose: an index would invite a caller to serve the bridge on a lenient match, which is exactly the tier boundary this method exists to draw. Only Registry.Lookup may select a profile.

The scan is the same non-breaking constant-time one Lookup uses.

func (*Registry) Len

func (r *Registry) Len() int

Len is the number of configured profiles.

func (*Registry) Lookup

func (r *Registry) Lookup(param string) (int, bool)

Lookup returns the index of the profile a bridge parameter selects, in the order the profiles were given to NewRegistry. A false result does NOT mean the value was unauthentic; ask Registry.IsAuthentic before falling through to the cover site.

The scan visits every entry and never breaks early, so its duration does not depend on WHICH profile matched or on whether one did -- that would tell an unauthenticated prober something about the secrets this server holds. Parse failures do return early: they depend only on the attacker's own input.

type SessionOptions

type SessionOptions struct {
	Backend     Backend
	Limits      frame.Limits
	Global      *budget
	Logger      mtglib.Logger
	ClientAddr  net.Addr
	MaxStreams  int
	DialTimeout time.Duration

	// PendingBytes and PendingItems bound this one session's downlink queue.
	// Zero means the defaults. They are separate from the global budget because
	// the two answer different questions: the global one stops the process
	// growing without limit, this one stops a single session doing it.
	PendingBytes int64
	PendingItems int64
}

SessionOptions configures one carrier session.

type Signer

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

Signer mints and recognises tokens.

func LoadTokenKey

func LoadTokenKey(path string) (*Signer, error)

LoadTokenKey reads the persistent signing key.

Every failure here is a startup failure. A relay that generated its own key when the file was missing would run correctly until the first restart and then answer its own live clients with the cover site; one that accepted a world-readable key would let anyone who can read it mint credentials that look authentic to the recognition tier.

func NewSigner

func NewSigner(key []byte) (*Signer, error)

NewSigner builds a signer from raw key bytes.

func (*Signer) IsAuthentic

func (s *Signer) IsAuthentic(token string) bool

IsAuthentic reports whether token is an authentic token of ANY kind. This is the tier-two test: a true answer means the request must be answered locally with an uncacheable 404 rather than handed to the cover site, whatever else is wrong with it.

Both kinds are always checked, with no early return, so the answer's duration does not say which kind was presented.

func (*Signer) Mint

func (s *Signer) Mint(kind TokenKind) (string, error)

Mint returns a fresh token of one kind.

func (*Signer) Verify

func (s *Signer) Verify(token string, kind TokenKind) bool

Verify reports whether token is an authentic token of exactly this kind. It says nothing about whether the operation it is presented for is allowed.

type TCPBackend

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

TCPBackend opens one loopback connection per OPEN.

The connection is byte-transparent: the client applied Telegram's own MTProxy transform before any of this, so what arrives here is an obfuscated2 stream with the client's transport tag already inside it. This code must not look at it, reorder it or frame it -- which is why the only thing it ever prepends is a PROXY header, and only when the listener on the other end is expecting one.

Why the PROXY header is not its own setting

The relay and the proxy read the same configuration file, and the listener the backend address names already declares whether it parses a header. Making this a second, independent knob would let the two disagree, and the failure is silent and total: an unexpected header is read as the first bytes of the obfuscated handshake, so every stream dies at the same place for a reason nothing logs. So the caller derives this from the [[listener]] stanza, and the preflight refuses a backend address that names no listener at all.

func NewTCPBackend

func NewTCPBackend(address string, proxyProtocol bool, keepAlive time.Duration) (*TCPBackend, error)

NewTCPBackend builds a dialer for one backend address. proxyProtocol must mirror the listener at that address.

func (*TCPBackend) Address

func (b *TCPBackend) Address() string

Address is the backend this dials, for logs and the preflight.

func (*TCPBackend) Dial

func (b *TCPBackend) Dial(ctx context.Context, client net.Addr) (net.Conn, error)

Dial opens the backend connection for one stream. client is the browser's address as the origin learned it, and it is what the proxy will see as the remote peer -- the whole point of running the relay in this process rather than accepting a loopback address for every WEB user.

type TokenKind

type TokenKind byte

TokenKind distinguishes the two credentials, so a bootstrap can never be presented as a session token or the other way round.

const (
	// KindBootstrap is minted into one bridge page and spent once, on session
	// creation.
	KindBootstrap TokenKind = 0x01

	// KindSession authenticates the carrier and the teardown request.
	KindSession TokenKind = 0x02
)

The two kinds.

Directories

Path Synopsis
Package frame implements the shared-frame codec of Telegram's WEB proxy v1.
Package frame implements the shared-frame codec of Telegram's WEB proxy v1.
Package page generates the bridge document: one dynamically built, no-store HTML response carrying a single inline nonce-bearing script and nothing else.
Package page generates the bridge document: one dynamically built, no-store HTML response carrying a single inline nonce-bearing script and nothing else.
Package ws is the server half of RFC 6455, written rather than imported.
Package ws is the server half of RFC 6455, written rather than imported.

Jump to

Keyboard shortcuts

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