Skip to content

feat(tunnel): pluggable внешний SSH-туннель (cloudflared + chisel) (#79) - #82

Merged
axisrow merged 4 commits into
mainfrom
ao/clihost_af28bd122d-3/ssh-tunnel
Jun 28, 2026
Merged

feat(tunnel): pluggable внешний SSH-туннель (cloudflared + chisel) (#79)#82
axisrow merged 4 commits into
mainfrom
ao/clihost_af28bd122d-3/ssh-tunnel

Conversation

@axisrow

@axisrow axisrow commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

Реализует pluggable внешний SSH-туннель к sshd контейнера (порт 22) для окружений без проброса портов (Railway/PaaS). Оба провайдера в образе, выбор через SSH_TUNNEL_PROVIDERcloudflared по умолчанию, chisel альтернатива. Туннель независим от hapi (работает при INSTALL_HAPI=false).

Спецификация — в комментариях к #78/#79.

Изменения

  • DockerfileARG 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). ao-стейджи не затронуты.
  • entrypoint.sh — env-дефолты SSH_TUNNEL_* + gate-блок рядом с hapi (не внутри, → независим от hapi); switch cloudflared|chisel|невалид; fail-видимо на пустом токене/CHISEL_SERVER/отсутствии бинарника; строка подключения для дашборда (Показать SSH-строку подключения на дашборде #80) → ssh-url.
  • build.sh — проброс INSTALL_CLOUDFLARED/INSTALL_CHISEL как INSTALL_HERMES (не-npm).
  • .env.example / README.md / CLAUDE.md — env-набор, примеры подключения клиента, process-table, INSTALL-ключи.
  • tests/unit/test_shell_scripts.py — 7 новых тестов.

Новые переменные окружения

Variable Default Назначение
SSH_TUNNEL_ENABLED false Мастер-выключатель туннеля
SSH_TUNNEL_PROVIDER cloudflared cloudflared | chisel
CLOUDFLARE_TUNNEL_TOKEN - Токен named-туннеля (обязателен для cloudflared)
CLOUDFLARE_TUNNEL_HOSTNAME - Публичный hostname для дашборда (в лог не пишется)
CHISEL_SERVER - URL своего chisel server --reverse
CHISEL_AUTH - user:pass
CHISEL_REMOTE_PORT 2222 Порт на сервере под проброшенный :22

Build-time: INSTALL_CLOUDFLARED, INSTALL_CHISEL (default true).

Подключение клиента

# cloudflared (нужен локальный cloudflared):
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" hapi@$CLOUDFLARE_TUNNEL_HOSTNAME
# chisel (нативный ssh):
ssh -p $CHISEL_REMOTE_PORT hapi@<host из CHISEL_SERVER>
# к ao daemon: добавить -L 127.0.0.1:3001:127.0.0.1:3001

Тесты

python -m pytest tests/unit/289 passed, 166 subtests passed. bash -n чисто. docker build не гонялся (Docker Hub таймаутит в окружении).

Порты/volumes

Без изменений — туннель исходящий, новые порты не публикуются.

Closes #79

🤖 Generated with Claude Code

Даёт внешний доступ к 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
@axisrow

axisrow commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1/3)

Reviewed locally — Claude subagent + Codex companion (no GitHub bots pinged). Оба ревьюера независимо сошлись на одной критической проблеме безопасности.

Verdict Reviewer Finding Location
FIX claude + codex CLOUDFLARE_TUNNEL_TOKEN / CHISEL_AUTH попадали в argv процесса → видно в ps//proc/<pid>/cmdline (регрессия инварианта репо: ROOT_PASSWORD/PASSWORD_SECRET) entrypoint.sh (cloudflared/chisel ветки)
FIX codex env-значения интерполировались в sh -c-строку → возможна shell-инъекция ($(),backtick,;); неквотированный/невалидированный CHISEL_REMOTE_PORT entrypoint.sh tunnel block
SKIP→fixed claude $! ловил PID tee, а не туннеля → немедленный сбой маскировался entrypoint.sh
SKIP→fixed claude sed [a-z]+ не стрипал mixed-case scheme в дашборд-строке entrypoint.sh

Исправления (в этом раунде)

  • Добавлен argv-вариант run_as_hapi_argv: программа и аргументы передаются отдельными argv-элементами (без sh -c-строки), доп. env-пары (секреты) идут перед -- через runuser … envне в командной строке.
  • cloudflared: токен через TUNNEL_TOKEN env (читается нативно), --token убран.
  • chisel: AUTH через env; CHISEL_SERVER/R:port:... — отдельные argv.
  • Numeric-guard для CHISEL_REMOTE_PORT (как у PORT).
  • Лог через redirect >>"$LOG" 2>&1 вместо | tee$! указывает на сам туннель (чинит masking).
  • sed → [A-Za-z]+://.
  • Тесты: +secret-hygiene (статика), +поведенческий run_as_hapi_argv (секрет — env-пара, не argv; метахар-значение передаётся буквально и не исполняется — /tmp/pwned не создаётся), +numeric-guard, +redirect-not-tee.

python -m pytest tests/unit/295 passed, 166 subtests passed.

Triage цикла 1/3: 2 FIX (оба исправлены), 2 minor (применены попутно).

axisrow and others added 2 commits June 28, 2026 16:19
…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
@axisrow

axisrow commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 2/3)

Claude subagent + Codex companion. Расхождение ревьюеров, разобрано триажем:

Verdict Reviewer Finding Решение
APPROVE claude Фиксы цикла-1 корректны, секрет не в /proc/<tunnel-pid>/cmdline (env execvp), no new critical
FIX codex TUNNEL_TOKEN=/AUTH= как env NAME=VALUE аргументы кратко видны в argv runuser/env при запуске исправлено

Codex прав по факту (краткое окно), и раз можно убрать секрет из argv полностью — сделал это, не оставляя остаточную экспозицию.

Исправление

  • Секрет экспортируется в окружение лаунчера и передаётся в run_as_hapi_argv только по имени. util-linux runuser без --login по умолчанию НЕ чистит окружение (проверено по man), поэтому значение наследуется через privilege drop, не попадая ни в один argv.
  • --whitelist-environment намеренно НЕ используется — без --login это no-op (тоже из man).
  • Тест усилен: поведенческий проверяет, что значение секрета (включая метахар-payload a$(touch …)b\id`;echo`) приходит в окружение runuser и ни в одном argv-элементе не появляется; инъекция не срабатывает.

python -m pytest tests/unit/295 passed, 166 subtests passed. Коммит 53d7216.

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
@axisrow

axisrow commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

✅ Local review (cycle 3/3) — APPROVE, цикл завершён

Claude subagent + Codex companion — оба APPROVE, 0 findings.

  • Codex: approve (проверил по man Debian bookworm util-linux, что runuser без --login сохраняет окружение → секрет наследуется).
  • Claude: APPROVE, все пункты PASS (секрет отсутствует во всех argv цепочки запуска; наследование env корректно; нет регрессий set -u/quoting/backgrounding; numeric-guard на месте).

Финальная зачистка (e9b7eba)

Применены накопленные minor-замечания (только комментарии/докстринги, поведение не меняется): актуализированы устаревшие упоминания --whitelist-environment; добавлен коммент про ARG-scope для повторной декларации INSTALL_CLOUDFLARED/CHISEL.

Итог ревью-цикла (3 раунда)

Цикл Итог
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 (фича) → d43749b53d7216e9b7eba.

@axisrow
axisrow merged commit 58ebeab into main Jun 28, 2026
1 check passed
@axisrow
axisrow deleted the ao/clihost_af28bd122d-3/ssh-tunnel branch June 28, 2026 09:16
axisrow added a commit that referenced this pull request Jun 29, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Реализовать SSH-туннель: бинарник + старт в entrypoint

1 participant