Skip to content
hamaadrazaPublic

About

HLS Proxy written in RUST

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

24 Commits

Folders and files

Repository files navigation

hls-proxy

CI Release License: MIT

Play any HLS stream from your own domain — headers, CORS and all.

Give it a stream URL plus the headers that stream requires, and it hands back an .m3u8 that plays anywhere. Every playlist, segment and key is served from your domain, and the original request headers are reattached automatically all the way down to the last segment.

curl "http://localhost:8080/encode?url=https://example.com/master.m3u8&header=Referer:https://example.com/"
{
  "url": "http://localhost:8080/proxy/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUu...",
  "payload": "eyJ1cmwiOiJodHRwczovL2V4YW1wbGUu..."
}

Drop that url into hls.js, VLC, ffmpeg or a <video> tag and it just plays.

The problem it solves

A protected HLS stream usually refuses to play in a browser for three reasons:

  1. It demands headers on every request. Referer, Origin, a specific User-Agent — and not just for the playlist, but for all several thousand segments. Browser video players give you no way to attach custom headers to segment requests.
  2. CORS blocks it. The origin never sent Access-Control-Allow-Origin, so the browser refuses the response even when the request succeeds.
  3. The origin only trusts browsers. Some reject any client that doesn't look like one, right down to the TLS handshake.

hls-proxy sits in the middle and handles all three. Your player only ever talks to your domain, over plain CORS-enabled HTTP, and the proxy does the awkward part upstream.

Features

  • Headers propagate automatically. Set them once; every variant playlist, segment, AES key and init segment inherits them.
  • Stateless. Everything needed to fetch a resource is encoded in its URL, so there is no session store, nothing to expire, and you can run any number of instances behind a load balancer.
  • Segments stream through. Video bytes are never buffered in memory, and Range requests are forwarded so seeking works.
  • Live and VOD. Live playlists are re-fetched and rewritten on every refresh.
  • Handles real-world playlists. Master and media playlists, EXT-X-KEY, EXT-X-MAP, EXT-X-MEDIA, I-frame streams, low-latency EXT-X-PART, relative and absolute URLs, inline data: keys.
  • Browser-identical requests. Upstream fetches use a real browser's TLS and HTTP/2 fingerprint, so origins that fingerprint clients still serve them.
  • One binary. No runtime dependencies, no config file required.

Install

Download a binary from the releases page:

Platform Architecture Archive
Linux x86_64 hls-proxy-<tag>-x86_64-unknown-linux-gnu.tar.gz
Linux arm64 hls-proxy-<tag>-aarch64-unknown-linux-gnu.tar.gz
macOS Apple silicon hls-proxy-<tag>-aarch64-apple-darwin.tar.gz
macOS Intel hls-proxy-<tag>-x86_64-apple-darwin.tar.gz
Windows x86_64 hls-proxy-<tag>-x86_64-pc-windows-msvc.zip
Windows arm64 hls-proxy-<tag>-aarch64-pc-windows-msvc.zip

Every archive ships a .sha256 checksum. Linux builds target glibc 2.35 (Ubuntu 22.04) for broad compatibility.

Archive names use Rust's arch-vendor-os-abi target naming. unknown is just the vendor field for platforms without a single vendor, so x86_64-unknown-linux-gnu means "64-bit Linux, glibc" — the right choice for almost every Linux server. aarch64 is 64-bit ARM.

Or build it yourself — see docs/DEVELOPMENT.md:

cargo build --release

Usage

Start the server:

hls-proxy

It listens on 0.0.0.0:8080 and needs no configuration to work locally.

Build a stream URL

The /encode endpoint turns a stream and its headers into a playable URL:

curl "http://localhost:8080/encode?url=https://example.com/master.m3u8&header=Referer:https://example.com/&header=Origin:https://example.com"

Repeat header as many times as you need. For longer header sets, POST JSON instead:

curl -X POST http://localhost:8080/encode \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://example.com/master.m3u8",
    "headers": {
      "Referer": "https://example.com/",
      "User-Agent": "Mozilla/5.0 ..."
    }
  }'

Play it

<video id="video" controls></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
  const hls = new Hls();
  hls.loadSource("http://localhost:8080/proxy/eyJ1cmwiOi...");
  hls.attachMedia(document.getElementById("video"));
</script>

Or from the command line:

ffplay "http://localhost:8080/proxy/eyJ1cmwiOi..."
ffmpeg -i "http://localhost:8080/proxy/eyJ1cmwiOi..." -c copy out.mp4

Full endpoint and payload reference: docs/API.md.

Configuration

All configuration is environment variables. A local .env file is loaded if present — see .env.example.

Variable Default Meaning
BASE_URL (request Host) Public origin the rewritten URLs point at, e.g. https://hls-proxy.example.com. Falling back to the request host means local runs need no config.
BIND 0.0.0.0 Address to bind.
PORT 8080 Port to listen on.
DEFAULT_EMULATION chrome_137 Browser profile used for upstream requests.
DEFAULT_EMULATION_OS windows Platform that profile presents as.
PROXY_URL (none) Route upstream requests through this HTTP/HTTPS proxy, e.g. http://user:pass@host:port. Unset means direct connections.
PROXY_MODE always With PROXY_URL set: always proxies everything; fallback goes direct until a host 429s, then proxies just that host.
SEGMENT_MODE proxy proxy relays every segment; auto hands segments that work without special headers straight to the player as direct CDN links.
RUST_LOG hls_proxy=info Log filter.

Set BASE_URL when you deploy behind a domain:

BASE_URL=https://hls-proxy.example.com PORT=8080 hls-proxy

Routing upstream traffic through a proxy

When one provider serves many of your streams, all those requests leaving from a single IP is what earns you 429 Too Many Requests. Set PROXY_URL and every upstream fetch — playlists and segments, across all emulation profiles — goes out through the proxy instead:

PROXY_URL="http://user:pass@proxy.example.com:8080" hls-proxy

Credentials in the URL are sent to the proxy as Proxy-Authorization; only the redacted scheme://host:port is ever logged. A malformed PROXY_URL fails at startup rather than quietly falling back to direct connections. Only http and https proxies are supported. To spread load across several IPs, run one instance per proxy, or put a rotating proxy behind a single PROXY_URL.

always vs fallback

For live HLS the playlist is refreshed every few seconds and every segment is on the critical path, so an always-on proxy hop adds latency to requests that mostly would have succeeded direct. PROXY_MODE=fallback avoids that: it sends requests direct and only diverts a host to the proxy once that host actually returns a 429.

PROXY_URL="http://user:pass@proxy.example.com:8080" PROXY_MODE=fallback hls-proxy

How fallback behaves:

  • A direct 429 is retried once through the proxy, so the viewer still gets the segment — they never see the rate limit.
  • That host is then routed through the proxy for a cooldown (30s, doubling on each repeat, capped at 15 min; a Retry-After header is honored if longer). Other hosts are unaffected.
  • When the cooldown lapses, a single request probes direct while the rest keep using the proxy. If it succeeds, the host returns to direct; if it 429s, the cooldown escalates. This keeps a recovering host from triggering a thundering herd of concurrent probes that all get rate-limited at once.

Use always (the default) when hiding your origin server's IP from the provider matters more than per-request latency — in fallback mode the provider sees your real IP whenever it isn't rate-limiting you.

Serving segments directly (SEGMENT_MODE=auto)

Often the heavy part of a stream — the segments — sits on an open CDN that needs no special headers, while only the playlist requires them. Relaying those segments buys nothing and costs you the whole video's bandwidth twice (CDN → your server → viewer). SEGMENT_MODE=auto detects this and rewrites the playlist so the player fetches segments straight from the CDN, leaving your server to handle only the playlists (a few KB every few seconds):

SEGMENT_MODE=auto hls-proxy

How it decides, per host:

  • On the first media playlist for a host, it probes the first segment — a one-byte Range request from the same (direct) path the viewer will use, and without the payload's special headers. The verdict is cached per host (~10 min) and single-flighted, so it costs one small request per host, not per segment.
  • Open (a 2xx that isn't an HTML error page) → segments are emitted as direct absolute CDN URLs. Anything else → segments stay proxied. Every ambiguous signal falls back to proxying.
  • Browser players (requests carrying an Origin) additionally require the CDN to send a compatible Access-Control-Allow-Origin, or the segment fetch() would be blocked; native players (apps, TVs, VLC) don't need this.
  • Playlists and EXT-X-KEY decryption keys are always proxied — they're small, often gated, and the rewritten playlist is the only point of control we keep once segments go direct.

It composes with PROXY_MODE: PROXY_MODE decides how this server reaches the upstream, SEGMENT_MODE decides whether the player is sent to the CDN at all.

⚠️ Only enable this for providers whose segment URLs are not IP-locked. The probe runs from your server's IP; the viewer fetches from theirs. If a provider binds segment URLs to the requesting IP, the probe succeeds but the viewer's fetch fails — and because direct segments bypass this server, there is no feedback loop for us to detect it. When in doubt, leave it on proxy.

Deployment guides for systemd, Docker, nginx and Caddy: docs/DEPLOYMENT.md.

How it works

Every proxied URL has the form /proxy/{token}, where the token is base64url-encoded JSON describing what to fetch:

{
  "url": "https://example.com/live/master.m3u8",
  "headers": { "Referer": "https://example.com/" }
}

When the proxy fetches a playlist, it rewrites every URL inside it into a new token carrying the same headers. That is the whole trick — context propagates downward automatically, so a segment request arriving an hour later still knows exactly which headers it needs.

player                    hls-proxy                     origin
  │                           │                            │
  ├── /proxy/{master} ───────►│── GET master.m3u8 ────────►│
  │                           │   + Referer, Origin        │
  │◄── rewritten playlist ────┤◄── #EXTM3U ────────────────┤
  │    (URLs now point here)  │                            │
  │                           │                            │
  ├── /proxy/{segment} ──────►│── GET segment.ts ─────────►│
  │                           │   + the same headers       │
  │◄── streamed bytes ────────┤◄── video data ─────────────┤

Playlists are small, so they are buffered and rewritten. Everything else is streamed straight through without buffering.

Architecture and design decisions: docs/ARCHITECTURE.md.

Security

There is no authentication. Anyone who can reach the server can encode a payload and use your bandwidth as an open proxy. Keep it on a private network, put an authenticating reverse proxy in front of it, or restrict access at the firewall before exposing it publicly.

The SSRF guard rejects loopback, private, link-local (including cloud metadata at 169.254.169.254), carrier-NAT and reserved addresses. It understands IPv4 and IPv6, including IPv4-mapped forms like [::ffff:127.0.0.1] that name an IPv4 address in IPv6 syntax, and it blocks the name localhost. Every redirect hop is checked too, not just the URL in the token, so an origin cannot answer with a 302 to a private address.

It is still not a hard boundary. A hostname that resolves to a private address is fetched, because resolution happens inside the HTTP client where this check cannot see it. Restrict egress at the network level if that matters — see docs/DEPLOYMENT.md.

Proxied responses are returned with Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff. Without them, anyone could point the proxy at an HTML page and have it served from your domain, which would run script on your origin.

See docs/DEPLOYMENT.md for ways to lock it down.

Documentation

Document Contents
docs/API.md Endpoints, payload schema, status codes, errors
docs/DEPLOYMENT.md systemd, Docker, nginx/Caddy, TLS, hardening
docs/ARCHITECTURE.md How requests flow, design decisions, limits
docs/DEVELOPMENT.md Building, toolchain setup, tests, releasing

Contributing

Pull requests are welcome. CI enforces cargo fmt and cargo clippy -D warnings, so run this before pushing:

cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test

Releasing

Bump version in Cargo.toml, commit it, then push a v* tag — that tag is what triggers the release build:

git tag v0.1.0 && git push origin v0.1.0

Binaries for all six platforms are built on native runners and attached to a GitHub Release automatically. Full checklist, including how to undo a bad tag: docs/DEVELOPMENT.md.

License

MIT © Hamaad Raza

About

HLS Proxy written in RUST

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages