Building a Website Mirror with GOST Reverse Proxy¶
In some network environments, access to certain websites is unstable. Setting up a "mirror site" is a common solution: move the target site as-is onto your own domain or server, so visitors get the same content transparently.
But "mirroring a website" is far more than just forwarding the homepage HTML. A modern site (take GitHub for example) spreads its resources across multiple domains (github.com, raw.githubusercontent.com, avatars.githubusercontent.com, github.githubassets.com, …), and the links, redirects, cookies, and security policies inside its pages are all hard-coded to the original domain. A naive forward would send the user back to the origin on every link click and load images from the origin domain — the mirror would be pointless.
This article uses GitHub as an example to walk through how to turn a multi-domain site into a real mirror using GOST's reverse proxy.
Core Idea: Encode the "Return Route" into the URL¶
The one problem a mirror must solve is this: when a user clicks a link inside the mirror, how does GOST know which upstream domain that link originally pointed to?
The answer is to encode the upstream domain into the mirror URL:
Original URL: https://github.com/go-gost/gost
Mirror URL: http://127.0.0.1:8000/github.com/go-gost/gost
↑ ↑
mirror address first path segment = upstream domain
Put github.com as the first path segment. The whole loop then becomes a pair of inverse transforms:
| Direction | Transform | Mechanism |
|---|---|---|
| Outbound: upstream URL → mirror URL | https://github.com/... → http://mirror/github.com/... |
regex rewrite of response body / Location header |
| Return: mirror URL → upstream URL | http://mirror/github.com/... → request /... on github.com |
matcher.rule picks the node + rewriteURL strips the prefix + http.host restores Host |
One regex in each direction is enough to make every link inside the mirror route back into the mirror instead of bouncing to the origin.
One Node Serves One Upstream Domain¶
There is a constraint that must be stated first: a GOST node is one address + one SNI. When dialing github.com:443 over TLS, the SNI and certificate verification are pinned to that address, and the Host is also given statically by config. This means one node can only correctly serve one upstream domain — you cannot cram github.com and gist.github.com into the same node, because connecting to gist.github.com requires an SNI of gist.github.com, otherwise certificate verification fails.
So "mirroring GitHub" is essentially one node per upstream domain, with matcher.rule sharding by the first path segment. Fortunately these domains are all statically known at config time, so no runtime dynamic resolution is needed.
The one exception is
*.github.io(GitHub Pages): it is routed by the Host header rather than SNI, and all subdomains share a single*.github.iowildcard certificate and resolve to the same anycast pool. So this kind of "Host-routed" infinite subdomain can be served with one node +http.hostPatternthat restores the Host dynamically — see below.This also explains why a plaintext host prefix (
/github.com/...) is used here instead of base64: GOST's response-body rewrite is pure regex substitution, and a regex cannot compute base64. For sites like GitHub the upstream domains only ever take the shape[a-z0-9-]+\.(github|githubusercontent|githubassets)\.com, so the first path segment is completely safe, and plaintext is easy to debug (curl-readable).
Three Layers of Rewriting¶
Like LLM Routing, mirror rewriting has three layers, each mapping to one GOST capability.
1. Response Body Rewriting¶
The page HTML is full of links pointing at the origin. Use response body rewriting to swap their host for the mirror address:
rewriteResponseBody:
- type: text/html,application/json
match: 'https://(github\.com|raw\.githubusercontent\.com|...)'
replacement: 'http://127.0.0.1:8000/$1'
maxChunkSize: 8388608
$1 captures the whole host; after substitution the origin host naturally becomes the first path segment of the mirror URL, matching the return-route convention exactly.
2. Response Header Rewriting¶
Response headers hide a class of "covert origin references" that are more subtle than body links:
Location— 302/301 redirects. Manygithub.comrequests bounce back to the origin; the redirect target must be encoded as a mirror URL too, otherwise one redirect leaves the mirror.Set-Cookie'sDomain— login-state cookies are bound to the origin domain. Under anonymous browsing, ifDomainis not stripped, the cookie leaks across paths to other upstreams after the domain is removed.Content-Security-Policy/Strict-Transport-Security— CSP blocks loading mirror-domain resources, and HSTS forces HTTPS. The mirror runs over HTTP, so both must be deleted.
These are GOST's header rewriting capabilities (rewriteResponseHeader), working as "header-name regex + value regex replacement":
rewriteResponseHeader:
- name: '(?i)^location$'
match: 'https://github\.com'
replacement: 'http://127.0.0.1:8000/github.com'
- name: '(?i)^set-cookie$'
match: '(?i)domain=\.?github\.com;?\s*'
replacement: '' # value emptied → header deleted
- name: '(?i)^content-security-policy(-report-only)?$'
match: '.*'
replacement: ''
- name: '(?i)^strict-transport-security$'
match: '.*'
replacement: ''
3. Request Header Rewriting (Delete, Not Encode)¶
The request-header direction is the inverse of the response — when the browser hits the mirror, the Referer/Origin it sends are already mirror addresses (http://127.0.0.1:8000/...). They must not be "encoded forward" again, or the mirror address would leak to the upstream. The correct move is to delete them:
GitHub's CSRF protection relies on form tokens rather than Referer, so deletion is both simplest and safest.
4. Exception: Dynamic Host for *.github.io¶
Earlier I said "one node per upstream domain" because SNI is static. But *.github.io (GitHub Pages) is different: Fastly routes content by the Host header, not SNI. Verified empirically —
- all
*.github.ioresolve to the same anycast pool (185.199.108~111.153); - they share one
*.github.iowildcard certificate, so any subdomain as SNI passes verification; - on the same IP,
Host: microsoft.github.ioreturns Microsoft's page andHost: google.github.ioreturns Google's page — content is decided by the Host header.
So this kind of infinite subdomain doesn't need "one node per subdomain"; it needs a single node that extracts the Host from the path dynamically via http.hostPattern:
- name: githubio
matcher:
rule: PathRegexp(`^/[a-z0-9-]+\.github\.io/`)
addr: github.io:443
tls: { secure: true, serverName: github.io } # static SNI, wildcard cert covers it
http:
hostPattern: '^/([a-z0-9-]+\.github\.io)/' # extract host from path
host: '$1' # Host = first capture group
rewriteURL:
- { match: '^/[a-z0-9-]+\.github\.io/', replacement: '/' } # strip host prefix
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
hostPattern is a regex matched against the URL path; on a match it treats http.host as a template and expands the $1/$2 capture groups. It only rewrites the Host header — it does not change the dial target or SNI (both remain static) — so it fits Host-routed upstreams exactly, and that is its only capability difference from a normal node.
Complete Configuration Example¶
Below is an anonymous read-only GitHub mirror on GOST v3. The client hits GOST over HTTP (127.0.0.1:8000), and GOST dials the upstream over HTTPS with certificate verification — no MITM, no client-side certificate trust, end to end.
# gost.yaml
# The union of every mirrored domain, collapsed into a single source of truth
# via a YAML anchor. To add an upstream domain: add one entry here + one node.
mirrorOrigins: &origins 'https://(www\.github\.com|gist\.github\.com|api\.github\.com|codeload\.github\.com|github\.githubassets\.com|opengraph\.githubassets\.com|avatars\.githubusercontent\.com|raw\.githubusercontent\.com|camo\.githubusercontent\.com|user-images\.githubusercontent\.com|release-assets\.githubusercontent\.com|github\.com)'
services:
- name: github-mirror
addr: :8000
handler:
type: tcp
metadata:
sniffing: true
listener:
type: tcp
forwarder:
nodes:
# Fallback: the bare homepage GET / and any path without an origin prefix
- name: github-root
matcher:
rule: PathPrefix(`/`)
addr: github.com:443
tls: { secure: true, serverName: github.com }
http:
host: github.com
rewriteResponseBody:
- { type: 'text/html,application/json', match: *origins, replacement: 'http://127.0.0.1:8000/$1', maxChunkSize: 8388608 }
rewriteRequestHeader:
- { name: '(?i)^(referer|origin|x-forwarded-host)$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^location$', match: *origins, replacement: 'http://127.0.0.1:8000/$1' }
- { name: '(?i)^set-cookie$', match: '(?i)domain=\.?github\.com;?\s*', replacement: '' }
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
- { name: '(?i)^strict-transport-security$', match: '.*', replacement: '' }
# Main site
- name: github
matcher:
rule: PathPrefix(`/github.com/`) || PathPrefix(`/www.github.com/`)
addr: github.com:443
tls: { secure: true, serverName: github.com }
http:
host: github.com
rewriteURL:
- { match: '^/github\.com/', replacement: '/' } # strip the first segment
rewriteResponseBody:
- { type: 'text/html,application/json', match: *origins, replacement: 'http://127.0.0.1:8000/$1', maxChunkSize: 8388608 }
rewriteRequestHeader:
- { name: '(?i)^(referer|origin|x-forwarded-host)$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^location$', match: *origins, replacement: 'http://127.0.0.1:8000/$1' }
- { name: '(?i)^set-cookie$', match: '(?i)domain=\.?github\.com;?\s*', replacement: '' }
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
- { name: '(?i)^strict-transport-security$', match: '.*', replacement: '' }
# Gist code snippets
- name: gist
matcher:
rule: PathPrefix(`/gist.github.com/`)
addr: gist.github.com:443
tls: { secure: true, serverName: gist.github.com }
http:
host: gist.github.com
rewriteURL:
- { match: '^/gist\.github\.com/', replacement: '/' }
rewriteResponseBody:
- { type: 'text/html,application/json', match: *origins, replacement: 'http://127.0.0.1:8000/$1', maxChunkSize: 8388608 }
rewriteRequestHeader:
- { name: '(?i)^(referer|origin|x-forwarded-host)$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^location$', match: *origins, replacement: 'http://127.0.0.1:8000/$1' }
- { name: '(?i)^set-cookie$', match: '(?i)domain=\.?github\.com;?\s*', replacement: '' }
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
- { name: '(?i)^strict-transport-security$', match: '.*', replacement: '' }
# REST API (JSON)
- name: api
matcher:
rule: PathPrefix(`/api.github.com/`)
addr: api.github.com:443
tls: { secure: true, serverName: api.github.com }
http:
host: api.github.com
rewriteURL:
- { match: '^/api\.github\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^(referer|origin|x-forwarded-host)$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# Static assets (CSS/JS)
- name: githubassets
matcher:
rule: PathPrefix(`/github.githubassets.com/`)
addr: github.githubassets.com:443
tls: { secure: true, serverName: github.githubassets.com }
http:
host: github.githubassets.com
rewriteURL:
- { match: '^/github\.githubassets\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# Raw file content
- name: raw
matcher:
rule: PathPrefix(`/raw.githubusercontent.com/`)
addr: raw.githubusercontent.com:443
tls: { secure: true, serverName: raw.githubusercontent.com }
http:
host: raw.githubusercontent.com
rewriteURL:
- { match: '^/raw\.githubusercontent\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' } # prevent cross-origin cookie leak
rewriteResponseHeader:
- { name: '(?i)^location$', match: *origins, replacement: 'http://127.0.0.1:8000/$1' }
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# Avatars
- name: avatars
matcher:
rule: PathPrefix(`/avatars.githubusercontent.com/`)
addr: avatars.githubusercontent.com:443
tls: { secure: true, serverName: avatars.githubusercontent.com }
http:
host: avatars.githubusercontent.com
rewriteURL:
- { match: '^/avatars\.githubusercontent\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# Release asset downloads
- name: release-assets
matcher:
rule: PathPrefix(`/release-assets.githubusercontent.com/`)
addr: release-assets.githubusercontent.com:443
tls: { secure: true, serverName: release-assets.githubusercontent.com }
http:
host: release-assets.githubusercontent.com
rewriteURL:
- { match: '^/release-assets\.githubusercontent\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# Repo archive downloads: github.com/<o>/<r>/archive/... 302s here
- name: codeload
matcher:
rule: PathPrefix(`/codeload.github.com/`)
addr: codeload.github.com:443
tls: { secure: true, serverName: codeload.github.com }
http:
host: codeload.github.com
rewriteURL:
- { match: '^/codeload\.github\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# Proxied external images in READMEs/issues
- name: camo
matcher:
rule: PathPrefix(`/camo.githubusercontent.com/`)
addr: camo.githubusercontent.com:443
tls: { secure: true, serverName: camo.githubusercontent.com }
http:
host: camo.githubusercontent.com
rewriteURL:
- { match: '^/camo\.githubusercontent\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# User-uploaded images in issues/PRs/READMEs (legacy upload host)
- name: user-images
matcher:
rule: PathPrefix(`/user-images.githubusercontent.com/`)
addr: user-images.githubusercontent.com:443
tls: { secure: true, serverName: user-images.githubusercontent.com }
http:
host: user-images.githubusercontent.com
rewriteURL:
- { match: '^/user-images\.githubusercontent\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# Social preview / og:image cards
- name: opengraph
matcher:
rule: PathPrefix(`/opengraph.githubassets.com/`)
addr: opengraph.githubassets.com:443
tls: { secure: true, serverName: opengraph.githubassets.com }
http:
host: opengraph.githubassets.com
rewriteURL:
- { match: '^/opengraph\.githubassets\.com/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
# GitHub Pages (*.github.io): Host-routed, not SNI-routed — one node serves all subdomains
- name: githubio
matcher:
rule: PathRegexp(`^/[a-z0-9-]+\.github\.io/`)
addr: github.io:443
tls: { secure: true, serverName: github.io }
http:
hostPattern: '^/([a-z0-9-]+\.github\.io)/' # extract host from path
host: '$1'
rewriteURL:
- { match: '^/[a-z0-9-]+\.github\.io/', replacement: '/' }
rewriteRequestHeader:
- { name: '(?i)^cookie$', match: '.*', replacement: '' }
rewriteResponseHeader:
- { name: '(?i)^content-security-policy(-report-only)?$', match: '.*', replacement: '' }
log:
level: info
Start it:
Data Flow¶
A full round trip to a repository page:
GET http://127.0.0.1:8000/github.com/go-gost/gost
→ Sniffer detects HTTP, PathPrefix(`/github.com/`) picks the github node
→ rewriteURL strips the prefix: /github.com/go-gost/gost → /go-gost/gost
→ http.host restores: Host = github.com
→ rewriteRequestHeader deletes Referer/Origin
→ forwarded to https://github.com/go-gost/gost
Response (200 text/html)
→ rewriteResponseBody: https://github.com/... → http://127.0.0.1:8000/github.com/...
→ rewriteResponseHeader:
Location → encoded as mirror URL
Set-Cookie → strip Domain=github.com
CSP / HSTS → deleted
→ returned to client
Client clicks an image link http://127.0.0.1:8000/avatars.githubusercontent.com/u/28017
→ PathPrefix(`/avatars.githubusercontent.com/`) picks the avatars node
→ strip prefix + restore Host → forwarded to https://avatars.githubusercontent.com/u/28017
Client visits a Pages site http://127.0.0.1:8000/microsoft.github.io/
→ PathRegexp(`^/[a-z0-9-]+\.github\.io/`) picks the githubio node
→ hostPattern extracts Host = microsoft.github.io (SNI remains static github.io)
→ rewriteURL strips the prefix: /microsoft.github.io/ → /
→ forwarded to https://github.io/ with Host: microsoft.github.io
Caching¶
Every mirror request round-trips to GitHub, and pages are large with many assets; repeated round-trips are both slow and wasteful. GOST's HTTP response cache adds a caching layer so hits return locally without touching the upstream.
The key is a seemingly contradictory point: GitHub sends Cache-Control: max-age=0, private on page responses, so caching by that header by default caches nothing. But for an anonymous read-only mirror this is precisely what makes it safe — all users see the same public content, with no per-user private data, so cross-user hits leak nothing. Hence we explicitly ignore the upstream Cache-Control and use our own TTL.
Three changes to the config above are all it takes:
services:
- name: github-mirror
addr: :8000
cache: mirror-cache # ① service level: reference a named cache store
handler:
type: tcp
metadata:
sniffing: true
# ② cache policy (handler metadata)
cache.ttl: 10m # default TTL (when no status override)
cache.status.200: 30m # 200 (pages/JSON/assets) live longer
cache.serveStale: true # serve stale cache as fallback on upstream failure
cache.maxBodyBytes: 8388608 # 8MB cap; larger raw/release files are not cached
# ③ named cache store: in-memory backend + LRU eviction
caches:
- name: mirror-cache
memory:
maxBytes: 268435456 # 256MB total byte cap, LRU eviction beyond it
eviction: lru
cache.ttl/cache.status.<code>control the lifetime of each response class; for anonymous public content, caching pages for 30 minutes is usually harmless.cache.serveStalefalls back to an expired entry when GitHub is unreachable — especially useful for a mirror, so users can still read content during an upstream blip.cache.maxBodyBytescaps the size of a single cached response; anything larger is passed through uncached to avoid stuffing big files into memory.
The cache key is computed from the request's Method + Host + RequestURI. Because the return route encodes the upstream domain into the path, same-named requests on different upstream domains naturally never collide.
Boundaries¶
This approach serves anonymous read-only browsing — public repos, READMEs, avatars, raw files, archive downloads. The following are out of scope and not deliberately accommodated:
- Login state — the login flow has multiple hard breaks: the CAPTCHA third-party iframe, WebAuthn's
rpIdbound to the origin, and Secure cookies requiring HTTPS, all of which an HTTP mirror cannot close the loop on. - JS runtime-injected URLs — addresses assembled by scripts at runtime cannot be covered by static rewriting; they need a heavier solution.
- Standalone sites like
docs.github.com— it has its own set of asset origins, and a partial mirror is worse than none, so it's simply excluded.
The value of a mirror is "you can browse, read, and download" — not to fully replicate a logged-in SaaS. Holding that boundary, covering the most content with the fewest mechanisms, is exactly the elegance of reverse-proxy mirroring.