EN | RU
hunshcn/gh-proxy fork in Go combined with new features for personal preferences and purposes of other contributors or/and like-minded people. It mounts on a path rather than the domain root, so it lives next to an existing site; it never announces itself — anything unauthenticated is a plain 404; and it is token-gated by default.
https://sub.example.com/ivanghproxy/TOKEN/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz
└──────── your site ───┘└─ prefix ──┘└───┘ └──────────────── an ordinary GitHub URL ──────────────────────┘
token
- releases, branch and tag archives,
blob/raw, gists; git cloneandfetch(git smart HTTP) with no git configuration;pushworks too, but only with a write-capableGHP_UPSTREAM_TOKEN: the client's own credentials are stripped and never reach GitHub;- resumable and parallel downloads (
Rangepasses through); - server-side redirect following to GitHub's CDN backends;
- private repositories through your own PAT (
GHP_UPSTREAM_TOKEN); - restriction by owner/repository (allow and deny lists);
- an optional short URL form that drops the
https://github.compart; - an optional status page with counters, a per-minute chart and Prometheus metrics.
git clone https://github.com/prettyleaf/gh-proxy && cd gh-proxy
cp .env.example .env
openssl rand -hex 24 # set GHP_PREFIX in .env
docker compose up -dlocation = /ivanghproxy {
return 404;
}
location /ivanghproxy/ {
proxy_pass http://127.0.0.1:8899;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 0;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
proxy_redirect off;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
access_log off;
}Check:
BASE='https://sub.example.com/ivanghproxy/YOUR_TOKEN'
curl -LO "$BASE/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz"
git clone "$BASE/https://github.com/cli/browser"
curl -s -o /dev/null -w '%{http_code}\n' https://sub.example.com/ivanghproxy/ # 404See docs/nginx.md for more info.
The documents below are in Russian.
| docs/nginx.md | mounting on a path: working config, every directive explained, verification, troubleshooting, Caddy/Traefik/Cloudflare |
| docs/clients.md | git, curl, wget, aria2, insteadOf, CI, Dockerfile |
| docs/security.md | threat model: stealth 404, token leaks, SSRF boundaries, what never reaches GitHub |
Four equivalent ways:
# path segment — the primary one, and the only one git handles out of the box
curl "$BASE/https://raw.githubusercontent.com/cli/cli/trunk/README.md"
# headers — for curl/wget/CI
curl -H "Authorization: Bearer TOKEN" "https://sub.example.com/ivanghproxy/https://..."
curl -u "x:TOKEN" "https://sub.example.com/ivanghproxy/https://..."
curl -H "X-Proxy-Token: TOKEN" "https://sub.example.com/ivanghproxy/https://..."The token belongs in the path because the proxy answers 404 rather than 401: git makes its first request without credentials and waits for a 401 WWW-Authenticate to learn that it should authenticate. A stealth 404 never issues that challenge — and a token already written into the URL makes the exchange unnecessary.
GHP_DEFAULT_HOST lets the mount point stand in for GitHub itself, so the URL is
the GitHub one with the host chopped off:
GHP_DEFAULT_HOST=github.com,raw.githubusercontent.comhttps://github.com/prettyleaf/media/blob/main/logo.png
https://sub.example.com/ivanghproxy/TOKEN/prettyleaf/media/blob/main/logo.png # the same file
The hosts are tried in order: github.com shapes first (blob/raw, releases,
archives, tags, git), then raw.githubusercontent.com, which is what makes a
bare /owner/repo/ref/path work too:
curl -O "$BASE/prettyleaf/media/main/logo.png" # -> raw.githubusercontent.com
curl -LO "$BASE/cli/cli/releases/download/v1/gh.tar.gz" # -> github.com
git clone "$BASE/cli/browser"Only the hosts the proxy already talks to are accepted here; anything else is a startup error. A URL that does name a host keeps its meaning — it is never re-read as an owner name.
Empty by default, and worth keeping that way unless you want a mirror: with it
on, every /owner/repo/... path below the mount point is proxied, so it will
shadow real paths if you mount at GHP_PREFIX=/ on a domain that serves
anything else. The prefix and the token still gate access.
A single self-contained page — no external assets, no database, counters held in memory and reset on restart: requests and bytes served, denials by reason, targets by URL kind, top repositories, the last 25 requests, a chart of the last hour, and the configuration the process is actually running with. The token is not on it.
GHP_STATUS_PATH=/ghp-status # off unless this is sethttps://sub.example.com/ghp-status then serves the page, with
/ghp-status/json (what the page polls, every 5 s) and /ghp-status/metrics
(Prometheus text format) beside it.
The header carries the running version; clicking it opens the build behind it — branch, commit, build time, CI run number — next to the repository's star count and a refresh button for when five seconds is too long to wait. The version goes amber and pulses when a newer release exists. Those two numbers are the only thing on the page that comes from outside, and the browser fetches them straight from api.github.com, cached for half an hour: the proxy itself makes no such call, and a viewer who cannot reach GitHub gets the whole page anyway, without the counts.
The path is a full path from the site root rather than something under
GHP_PREFIX, so it is one location for the reverse proxy to guard:
GHP_STATUS_AUTH |
|
|---|---|
token (default) |
the proxy's own credential: any of the four ways, plus ?token=... for opening it in a browser |
none |
the proxy checks nothing — for when tinyauth, basic auth or an SSO forward-auth already guards that location. Config in docs/nginx.md |
Unset, everything under the status path answers the same 404 as the rest of
the service, and the same goes for a wrong token or an unknown sub-path.
The admin listener carries the same thing unconditionally at /status,
/status/json and /metrics — it is bound to loopback, so reaching it already
means being on the host:
curl -s 127.0.0.1:8900/metricsStatus page requests are never counted as traffic; the page polling itself would otherwise be the only thing on the chart.
GHP_ALLOW_ANONYMOUS=1 disables authentication entirely. GHP_TOKEN must then
be empty; setting both is a startup error, as is setting neither — the proxy
refuses to boot rather than quietly turn into an open relay.
GHP_ALLOW_ANONYMOUS=1 GHP_PREFIX=/ivanghproxy/ ./bin/gh-proxyNo token segment is consumed from the path, so the URL is just the prefix followed by the GitHub URL:
BASE='https://sub.example.com/ivanghproxy'
curl -LO "$BASE/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz"Private repositories need a credential the proxy presents to GitHub. Instead of
minting a PAT and pasting it into .env, point the proxy at a gh CLI that is
already logged in:
gh auth login # once, as the user that runs the proxy
GHP_UPSTREAM_TOKEN_SOURCE=gh # in .envStartup refuses if gh is missing or logged out, so a broken setup is a boot
error rather than an unexplained 404 on the first private download. The
credential is re-read every GHP_GH_REFRESH (default 5m), because gh rotates
OAuth tokens on its own schedule. GHP_UPSTREAM_TOKEN and this source together
are a startup error — pick one.
Two lookups, in order:
gh auth token --hostname github.com, whenever the binary is reachable.$GHP_GH_CONFIG_DIR/hosts.yml, when it is not.
The second one is what makes this work in Docker: the image is built on
scratch and has no gh to run, so mount gh's config directory read-only. The
container must run as a uid that can read hosts.yml (gh writes it 0600):
services:
gh-proxy:
user: "1000:1000" # the uid that owns ~/.config/gh
volumes:
- ${HOME}/.config/gh:/gh:ro
environment:
GHP_UPSTREAM_TOKEN_SOURCE: gh
GHP_GH_CONFIG_DIR: /ghIf gh stores the token in a system keyring rather than in hosts.yml, only the
gh binary can read it back — either run the proxy outside the container, or keep
using GHP_UPSTREAM_TOKEN.
Worth being explicit about the trade: this hands the proxy your whole GitHub
account, so anyone holding GHP_TOKEN reaches everything you can. A PAT scoped
to contents: read on just the repositories you serve is tighter. Either way,
bound it with GHP_ALLOW_LIST.
All of them are environment variables; the full annotated list is in .env.example.
| Variable | Default | |
|---|---|---|
GHP_TOKEN |
— | required unless GHP_ALLOW_ANONYMOUS=1. Secret, ≥16 characters, no /?# or spaces |
GHP_TOKEN_FILE |
— | read the token from a file (docker secrets) |
GHP_PREFIX |
/ |
mount point, must match the nginx location |
GHP_LISTEN |
0.0.0.0:8899 |
public listener |
GHP_ADMIN_LISTEN |
127.0.0.1:8900 |
/healthz, never published |
GHP_ALLOW_LIST |
empty | ivan, ivan/repo, */repo — empty means "any" |
GHP_DENY_LIST |
empty | same syntax, applied after the allow list |
GHP_DEFAULT_HOST |
empty | hosts tried when the URL names none, in order: github.com,raw.githubusercontent.com. See short form |
GHP_UPSTREAM_TOKEN |
— | GitHub PAT for private repos and rate limits |
GHP_UPSTREAM_TOKEN_SOURCE |
env |
gh takes that credential from an authenticated gh CLI instead. See private repos without a PAT |
GHP_GH_BIN / GHP_GH_HOST |
gh / github.com |
binary and GitHub host for the gh source |
GHP_GH_CONFIG_DIR |
gh's own default | where hosts.yml is looked up when the binary is unavailable |
GHP_GH_REFRESH |
5m |
how often the gh credential is re-read; 0 never |
GHP_SIZE_LIMIT |
0 |
over the limit → 302 to the real GitHub. 512MB, 2GB |
GHP_REDIRECT_HOSTS |
GitHub CDNs | where redirects may be followed (replaces the default) |
GHP_MAX_REDIRECTS |
5 |
|
GHP_STATUS_PATH |
empty | path of the status page; empty disables it |
GHP_STATUS_AUTH |
token |
none leaves the page to the reverse proxy's own authentication |
GHP_CORS |
0 |
allow fetch() from a browser |
GHP_LOG_TARGETS |
0 |
log upstream URLs (with a token in the path, that logs secrets) |
GHP_ALLOW_ANONYMOUS |
0 |
disable authentication — open relay |
make test # go test ./...
make race # go test -race
make lint # go vet + gofmt
make build # bin/gh-proxy
make run # local run with a throwaway token
make token # openssl rand -hex 24Locally without Docker:
GHP_TOKEN=local-dev-token-0123456789 GHP_PREFIX=/ivanghproxy/ \
GHP_LISTEN=127.0.0.1:8899 ./bin/gh-proxy| Tag | Built from |
|---|---|
ghcr.io/prettyleaf/gh-proxy:latest, :X.Y.Z |
a v* tag (docker.yml) |
ghcr.io/prettyleaf/gh-proxy:dev |
every push to dev, once tests pass (build-dev.yml) |
ghcr.io/prettyleaf/gh-proxy:dev-<sha> |
the same build, pinned to its commit |
The dev image reports dev-<sha> as its version, on /healthz and on the
status page, so a running container names the commit it came from. To follow it,
change the image in docker-compose.yml and pull:
docker compose pull && docker compose up -dBoth workflows also run from the Actions tab (workflow_dispatch). Setting the
TELEGRAM_TOKEN, TELEGRAM_CHAT_ID and TELEGRAM_TOPIC_ID secrets turns on
build notifications; without them those steps skip silently.
MIT