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.

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.

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

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

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages