Задача: повесить прокси на https://sub.example.com/ivanghproxy/ рядом с уже работающим сайтом, ничего в нём не сломав и не выдав факт существования сервиса.
server {
listen 443 ssl;
http2 on;
server_name sub.example.com;
server_tokens off;
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;
}
}GHP_PREFIX в .env должен совпадать с location:
GHP_PREFIX=/ivanghproxy/.
If proxy_pass is specified without a URI, the request URI is passed to the server in the same form as sent by a client when the original request is processed, or the full normalized request URI is passed when processing the changed URI.
Проксируемый URL выглядит так:
/ivanghproxy/TOKEN/https://github.com/cli/cli/releases/download/v1/my%20file.zip
↑↑ ↑↑↑
двойной слеш percent-encoding
«Same form as sent by a client» = сырой request-target: // на месте, %20 не тронут. Ровно то, что нужно.
Стоит дописать / в конце proxy_pass — и nginx переключается на нормализованный URI: схлопывает // в / и перекодирует escape-последовательности. https://github.com превращается в https:/github.com, а имя файла с %2F внутри — в путь с настоящим слешем.
Любая из этих директив создаёт «changed URI», и nginx по той же цитате из документации начинает слать апстриму нормализованную форму — эффект точно такой же, как в пункте. В частности, не пытайтесь срезать префикс через
location /ivanghproxy/ {
rewrite ^/ivanghproxy/(.*) /$1 break;
proxy_pass http://127.0.0.1:8899;
}Префикс срезает само приложение — для этого и существует GHP_PREFIX.
Три директивы, без которых ломается git и большие файлы:
| Директива | Что будет без неё |
|---|---|
proxy_buffering off |
git clone виснет на согласовании pack-файла: обе стороны ждут байты, лежащие в буфере nginx |
proxy_request_buffering off |
nginx сначала целиком принимает тело POST /git-upload-pack, потом шлёт — на больших репах это таймаут |
client_max_body_size 0 |
413 Request Entity Too Large на git push и на больших запросах upload-pack (дефолт всего 1 МБ) |
proxy_read_timeout/proxy_send_timeout подняты до часа, потому что при git clone большого репозитория GitHub может молчать несколько минут, пока считает pack.
При токене-в-пути (/ivanghproxy/TOKEN/https://...) дефолтный combined формат сохраняет секрет открытым текстом в файл, который читает кто угодно с доступом к серверу и который попадает в ротацию и бэкапы.
Варианты:
access_log off;или, если статистика всё же нужна:
# в http {}
log_format ghproxy '$remote_addr $status $body_bytes_sent $request_time';
# в location
access_log /var/log/nginx/ghproxy.log ghproxy;Само приложение URL-ы не логирует: GHP_LOG_TARGETS=0 по умолчанию.
Disallow: /ivanghproxy/ # это публикация секретного пути, а не защита
robots.txt читается всеми и первым делом. Для той же цели служит заголовок X-Robots-Tag: noindex — приложение ставит его само, в конфиге он продублирован на случай ответов, сгенерированных nginx.
Обратите внимание:
add_headerвlocationотменяет всеadd_header, унаследованные с уровняserver. Если у вас там HSTS и прочее — продублируйте их в этом блоке.
nginx -t && systemctl reload nginx
BASE='https://sub.example.com/ivanghproxy/ВАШ_ТОКЕН'
# 1. сервис молчит для всех, кто не знает секрета
curl -s -o /dev/null -w '%{http_code}\n' https://sub.example.com/ivanghproxy # 404, НЕ 301
curl -s -o /dev/null -w '%{http_code}\n' https://sub.example.com/ivanghproxy/ # 404
curl -s -o /dev/null -w '%{http_code}\n' "https://sub.example.com/ivanghproxy/нет/https://github.com/cli/cli/tags" # 404
# 2. префикс подключён правильно (это провалится при proxy_pass со слешем)
curl -sI "$BASE/https://raw.githubusercontent.com/cli/cli/trunk/README.md" | head -1 # 200
# 3. percent-encoding доезжает целым — главный индикатор пункта (1)
curl -s -o /dev/null -w '%{http_code}\n' \
"$BASE/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz" # 200
# 4. редирект на CDN отрабатывает на сервере, а не отдаётся вам
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
"$BASE/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz" # 200 13065800
# 5. докачка
curl -s -r 0-99 -o /dev/null -w '%{http_code} %{size_download}\n' "$BASE/<любой релиз>" # 206 100
# 6. git
git clone --depth 1 "$BASE/https://github.com/cli/browser" /tmp/probe && rm -rf /tmp/probe
# 7. в логах нет токена
sudo grep -c "ВАШ_ТОКЕН" /var/log/nginx/*.log # 0| Симптом | Причина |
|---|---|
| 404 на всё, даже с верным токеном | GHP_PREFIX не совпадает с location. Он должен быть с обоими слешами: /ivanghproxy/ |
404 от GitHub на файлы с пробелами/+ в имени |
proxy_pass со слешем на конце → nginx перекодировал %XX. Пункт (1) |
git clone доходит до «Resolving deltas» и виснет |
нет proxy_buffering off |
413 при git clone большого репо |
нет client_max_body_size 0 |
502 в браузере, в логе приложения redirect to disallowed host |
GitHub перекинул на хост не из списка. Добавьте его в GHP_REDIRECT_HOSTS (полный список нужно указывать целиком — он заменяет дефолтный, а не дополняет) |
301 вместо 404 на /ivanghproxy |
нет блока location = /ivanghproxy { return 404; } |
| Всё работает, но качается медленно и рывками | proxy_buffering включён где-то выше по конфигу |
Логи приложения:
docker compose logs -f gh-proxyДля отладки временно: GHP_LOG_LEVEL=debug (покажет причину каждого 404) и
GHP_LOG_TARGETS=1 (покажет upstream-URL в ошибках). Оба возвращайте обратно
после отладки — вместе они пишут в лог полные URL.
GHP_STATUS_PATH вешает страницу со счётчиками на отдельный путь от корня
сайта, а не внутри GHP_PREFIX. Смысл ровно в этом: получается один
location, перед которым можно поставить свою аутентификацию, не трогая всё
остальное.
# .env
GHP_STATUS_PATH=/ghp-status
GHP_STATUS_AUTH=none # проверять будет nginx, см. нижеlocation /ghp-status {
auth_request /tinyauth;
error_page 401 = @tinyauth_login;
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;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
access_log off;
}
location = /tinyauth {
internal;
proxy_pass http://127.0.0.1:3000/api/auth/nginx;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
}
location @tinyauth_login {
return 302 https://tinyauth.example.com/login?redirect_uri=$scheme://$http_host$request_uri;
}Три заголовка X-Forwarded-Proto, X-Forwarded-Host и X-Forwarded-Uri
tinyauth требует обязательно — без любого из них подзапрос падает, а location
отдаёт 500 вместо формы логина. Ей самой стоит задать
TINYAUTH_AUTH_TRUSTEDPROXIES с адресом nginx, иначе она не поверит
X-Real-IP/X-Forwarded-For. Точные имена эндпоинта и переменных зависят от
версии — сверяйтесь с документацией tinyauth.
Без слеша в proxy_pass путь доезжает до приложения как есть, поэтому
GHP_STATUS_PATH должен совпадать с location в точности. Под этим же путём
живут /ghp-status/json (страница опрашивает его раз в 5 с) и
/ghp-status/metrics — оба закрываются тем же auth_request, потому что
location без = покрывает и вложенные пути.
То же самое через basic-аутентификацию, если tinyauth не нужен:
location /ghp-status {
auth_basic "gh-proxy";
auth_basic_user_file /etc/nginx/ghp-status.htpasswd;
proxy_pass http://127.0.0.1:8899;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
access_log off;
}Внимание: с GHP_STATUS_AUTH=none приложение не проверяет вообще ничего — если
location окажется без auth_request/auth_basic, страница станет публичной.
Проверять так:
curl -s -o /dev/null -w '%{http_code}
' https://sub.example.com/ghp-status # 302 или 401, не 200
curl -s -o /dev/null -w '%{http_code}
' https://sub.example.com/ghp-status/json # то же самоеОставить проверку приложению — GHP_STATUS_AUTH=token (по умолчанию): тогда
страница требует тот же токен, что и прокси, и location можно проксировать
без auth_request. В браузере открывается как
https://sub.example.com/ghp-status?token=ВАШ_ТОКЕН — токен при этом попадёт в
access_log nginx, если он не выключен для этого блока.
Метрики без публичного пути вообще: админский listener отдаёт /status,
/status/json и /metrics всегда, но слушает только loopback.
curl -s 127.0.0.1:8900/metricssub.example.com {
handle_path /ivanghproxy/* {
# handle_path срезает префикс, поэтому приложение монтируем в корень
reverse_proxy 127.0.0.1:8899 {
flush_interval -1
}
}
}При этом GHP_PREFIX=/ (Caddy уже срезал префикс). Caddy нормализует путь и
схлопывает // — приложение чинит это само, но percent-encoding Caddy
сохраняет, так что имена файлов не страдают.
Cloudflare нормализует URL и схлопнет //. Приложение это чинит, но учтите два
момента: Cloudflare кэширует ответы (включая приватные файлы — настройте Cache
Rules на bypass для этого пути) и обрывает соединение по таймауту 100 секунд на
свободном тарифе, чего может не хватить для большого git clone.
# в http {}
limit_req_zone $binary_remote_addr zone=ghproxy:10m rate=10r/s;
# в location /ivanghproxy/
limit_req zone=ghproxy burst=40 nodelay;
# только из своей сети/VPN — если весь трафик идёт оттуда
allow 10.8.0.0/24;
allow 203.0.113.5;
deny all;