Skip to content

Repository files navigation

gh-proxy

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

What it does

  • releases, branch and tag archives, blob/raw, gists;
  • git clone and fetch (git smart HTTP) with no git configuration; push works too, but only with a write-capable GHP_UPSTREAM_TOKEN: the client's own credentials are stripped and never reach GitHub;
  • resumable and parallel downloads (Range passes 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.com part;
  • an optional status page with counters, a per-minute chart and Prometheus metrics.

Quick start

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 -d

Reverse-proxy

location = /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/   # 404

See docs/nginx.md for more info.

Documentation

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

How the token is passed

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.

Short form

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.com
https://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.

Status page

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 set

https://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/metrics

Status page requests are never counted as traffic; the page polling itself would otherwise be the only thing on the chart.

Running without a token

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-proxy

No 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 repos without a PAT

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 .env

Startup 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:

  1. gh auth token --hostname github.com, whenever the binary is reachable.
  2. $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: /gh

If 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.

Settings

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

Development

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 24

Locally without Docker:

GHP_TOKEN=local-dev-token-0123456789 GHP_PREFIX=/ivanghproxy/ \
GHP_LISTEN=127.0.0.1:8899 ./bin/gh-proxy

Images

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 -d

Both 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.

License

MIT

About

hunshcn/gh-proxy fork in Go combined with new features for personal preferences and purposes of other contributors or/and like-minded people.

Topics

Resources

Security policy

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages