Внутреннее устройство прокси исходящего трафика

Страница описывает архитектуру файрвола с подстановкой учётных данных (hermes egress и iron-proxy) с точки зрения того, кто пишет код или плагины. Настройка и использование для конечного пользователя описаны в разделе Прокси исходящего трафика.

Модель угроз и общая идея изложены на пользовательской странице; здесь — как всё это связано, где лежит код, важный для безопасности, и какие инварианты придётся сохранить, если вы возьмётесь его править.

Раскладка модулей

pm/security_packages.py              Pinned binary acquisition and publication
                                       through PM. Release provenance staging.

agent/proxy_sources/iron_proxy.py     Core: binary lookup, GPG checks, CA gen, config build,
                                       subprocess lifecycle, mappings I/O, PID/nonce
                                       defense.  Pure-function surface where possible.

hermes_cli/proxy_cli.py               Wizard + slash command handlers.
                                       `hermes egress {install,setup,start,stop,
                                       status,disable,config}`.  Wires the
                                       core module into argparse.

hermes_cli/subcommands/egress.py:_dispatch_egress
                                       Top-level subparser dispatcher.
                                       dest='egress_command' (intentionally
                                       disjoint from the inbound OAuth
                                       `hermes proxy` subparser, which uses
                                       dest='proxy_command').

hermes_cli/config.py: proxy schema    The `proxy:` block in DEFAULT_CONFIG.
                                       Adding a knob means: add it here, add a
                                       wizard prompt or `setdefault` in
                                       proxy_cli.cmd_setup, and document it
                                       in the user-guide page.

tools/environments/docker.py
  _egress_proxy_args_for_docker()     Builds the volume_args / env_overrides /
                                       host_args triple that the Docker backend
                                       injects when `proxy.enabled: true`.

  DockerEnvironment.__init__          Docker-side merge logic: collision
                                       detection against critical egress vars,
                                       NODE_OPTIONS append-merge via the
                                       _HERMES_EGRESS_NODE_OPTIONS_APPEND
                                       sentinel, enforce_on_docker precedence.

tests/agent/test_iron_proxy.py              Hermetic tests (~70).  Binary install
                                       path, config build, mappings I/O,
                                       subprocess lifecycle, docker arg builder,
                                       deny CIDR defaults, bind policy, CA
                                       TOCTOU, ensure_audit_log behaviour, etc.

tests/hermes_cli/test_iron_proxy_cli.py          CLI handler unit tests (~20).  Argparse
                                       wiring, fail-loud paths, BWS refresh
                                       wire-up, dest='egress_command'
                                       regression guard.

tests/agent/test_iron_proxy_e2e.py          Live E2E (gated on HERMES_RUN_E2E=1).
                                       Real iron-proxy binary, real curl,
                                       end-to-end token swap verified.

Жизненный цикл

hermes egress install
  -> agent.proxy_sources.iron_proxy.install_iron_proxy(force=...)
       pm.ensure("iron-proxy", explicit=True) installs or repairs the entry.
       PM checks archive and provenance hashes against pm/lock.json.
       Package staging checks that release checksums cover the pinned archive,
         then calls the GPG checker. Missing GPG permits hash-only installation.
         An explicit signature rejection aborts installation.
       PM publishes the entry and records its identity and digest in facts.
       pm.installed_package("iron-proxy").binary returns the selected path.
       _VERSION_CACHE.pop(target) makes the next status call probe --version.

hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens]
  -> proxy_cli.cmd_setup
       Step 1. find_iron_proxy(install_if_missing=False) -> install if absent.
       Step 2. ensure_ca_cert()
                 Run openssl genrsa + req via subprocess.
                 Write CA key via os.open(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW, 0o600)
                   + os.replace.  Never exists on disk under default umask.
                 Write CA cert with 0o644 (public).
       Step 3. discover_provider_mappings() or pull names from BWS via
                 fetch_bitwarden_secrets() when --from-bitwarden.
                 merge_mappings(existing=load_mappings(), discovered,
                                rotate=args.rotate_tokens) preserves prior
                 tokens unless --rotate-tokens is passed.
                 discover_uncovered_providers() and surface warnings.
       Step 4. ensure_audit_log(audit_log_path)   # raises on OSError
               build_proxy_config(...) with defaults applied at the call site
                 (deny CIDRs default, bind policy from _default_http_listen).
               write_proxy_config(cfg)            # atomic via .tmp + os.replace, 0o600
               write_mappings(mappings)           # atomic, 0o600
       Step 5. proxy_cfg["enabled"] = True; credential_source preservation logic
               (do NOT silently downgrade bitwarden -> env on re-run);
               save_config(cfg).

hermes egress start
  -> proxy_cli.cmd_start
       Pre-checks (refuse-start path):
         - credential_source=bitwarden? -> pre-validate access_token_env + project_id
       -> iron_proxy.start_proxy(
            refresh_secrets_from_bitwarden=...,
            bitwarden_config=...,
          )
            existing=_read_pid(); if alive, idempotent return.
            _build_proxy_subprocess_env(...):  ALLOWLIST + mapped real_env_names,
              strip HTTPS_PROXY/etc. to avoid recursion, optional BWS refresh
              (raises on missing values unless allow_env_fallback=true).
            Plant nonce: _proxy_nonce = sha256(urandom(16)); env[NONCE_ENV] = ...
            Open log_path via O_NOFOLLOW + 0o600 + st_uid check.
            Popen with stdin=DEVNULL, stdout=log_fd, stderr=STDOUT,
              start_new_session=True (POSIX).
            Close parent's log_fd in finally.
            _write_pidfile_safely(pidfile, proc.pid)
              O_EXCL + O_NOFOLLOW + uid check + persisted nonce sidecar.
              FileExistsError -> discriminate live vs stale, retry once if stale.
            Install SIGINT/SIGTERM handlers (main-thread only).
            Poll loop (do-while shape):
              while True:
                if proc.poll() is not None: tail log + unlink pidfile + raise
                if _port_listening(probe_host, tunnel_port): break  # probe_host = configured bind host
                if time.time() >= deadline: break  (do-while: checked AFTER first probe)
                time.sleep(0.1)
            If not listening at exit: _kill_and_wait(proc) + unlink pidfile + raise.

hermes egress stop
  -> iron_proxy.stop_proxy
       _read_pid + _pid_alive guard.
       starttime_before = _pid_proc_starttime(pid)   # Linux only; None elsewhere
       os.kill(pid, SIGTERM)
       Wait up to 5s for graceful exit.
       After grace: re-check starttime + _pid_alive.
         If recycled (starttime drift OR _pid_alive False), DO NOT SIGKILL.
         Otherwise os.kill(pid, _KILL_SIGNAL).
       _cleanup_state_files: unlink pidfile + nonce sibling.

Инварианты безопасности

Это несущие свойства. Правите модуль — обязаны их сохранить. Там, где есть регрессионный тест, он назван.

Права на файлы

ПутьПраваТест
~/.hermes/proxy/ (каталог)0o700test_proxy_state_dir_is_0o700
ca.key0o600test_ca_key_created_with_0o600
ca.crt0o644(неявно; вызов chmod в ensure_ca_cert)
proxy.yaml0o600(chmod после атомарного переименования в write_proxy_config)
mappings.json0o600(chmod после атомарного переименования в write_mappings)
iron-proxy.pid0o600(режим os.open(..., 0o600) в _write_pidfile_safely)
iron-proxy.nonce0o600(режим os.open(..., 0o600) в _write_pidfile_safely)
audit.log0o600test_ensure_audit_log_creates_with_0o600
iron-proxy.log0o600(os.open(..., 0o600) и fchmod)

Все пути записи используют os.open(O_WRONLY | O_CREAT | O_NOFOLLOW, 0o600) с проверкой os.fstat().st_uid. Связка shutil.copy2 + os.chmod запрещена: она оставляет окно с правами по умолчанию из umask.

Минимизация окружения подпроцесса

_build_proxy_subprocess_env НЕ ДОЛЖЕН пользоваться os.environ.copy(). Разрешённый список — _PROXY_SUBPROCESS_ENV_ALLOWLIST (PATH, HOME, локаль и подобное) плюс имена переменных, на которые ссылается load_mappings(). Всё остальное остаётся на хосте.

Регрессия: test_subprocess_env_strips_unrelated_secrets, test_subprocess_env_strips_proxy_recursion_vars, test_subprocess_env_keeps_infrastructure_vars.

Политика привязки к адресу

_default_http_listen возвращает список из одного элемента: на Linux — IP шлюза docker-моста (контейнеры доходят до прокси через host.docker.internal:host-gateway, который резолвится в шлюз моста, а привязка к loopback оттуда недостижима); в Docker Desktop на macOS и Windows — loopback (VPNkit маршрутизирует host.docker.internal на хост). Linux без обнаруженного моста docker0 откатывается к loopback с предупреждением. Никогда 0.0.0.0 и никогда :PORT (INADDR_ANY).

_detect_docker_bridge_ip проверяет адрес через ipaddress.IPv4Address и отвергает is_unspecified, is_loopback, is_multicast, is_reserved, is_link_local и is_global. Враждебная подмена ip в PATH подсунуть 0.0.0.0 не сможет.

Ограничение схемы v0.39 и роли слушателей (проверено вживую на бинарнике): в структуре config.Proxy есть только одиночные поля слушателей — списка http_listens во множественном числе не существует. tunnel_listen — слушатель CONNECT и MITM, куда приходит трафик HTTPS_PROXY; http_listen обрабатывает только пересылку обычного HTTP в абсолютной форме (посланный туда CONNECT уйдёт наверх обычным запросом и получит 400). Поэтому build_proxy_config вешает tunnel_listen на tunnel_port, а http_listen — на tunnel_port + 1, оба на платформенном адресе привязки. Бэкенд Docker выставляет HTTPS_PROXY на tunnel_port, а HTTP_PROXY — на tunnel_port + 1.

Проверки живости (цикл опроса в start_proxy, get_status) читают настроенный адрес привязки через _read_http_listen_from_config() и стучатся ИМЕННО туда — зашитая проверка loopback объявила бы здоровый демон на мосте мёртвым.

Регрессия: test_default_bind_is_loopback_not_zero_zero (проверяет отсутствие INADDR_ANY И отсутствие http_listens в отрендеренном yaml), test_default_bind_uses_docker_bridge_on_linux, test_default_bind_falls_back_to_loopback_without_bridge, test_default_bind_is_loopback_on_macos, test_detect_docker_bridge_ip_rejects_dangerous (параметризован по восьми атакующим входам).

Конфликт порта метрик

В iron-proxy v0.39 metrics.listen по умолчанию равен :9090 — ТОТ ЖЕ порт, что и tunnel_port: 9090 у Hermes. build_proxy_config ОБЯЗАН явно прибить metrics.listen: 127.0.0.1:0, чтобы метрики получали произвольный порт на loopback и не сталкивались со слушателем прокси, какой бы tunnel_port ни выбрал оператор.

Регрессия: test_metrics_listener_pinned_to_loopback_ephemeral.

Запрещённые CIDR по умолчанию

_DEFAULT_UPSTREAM_DENY_CIDRS покрывает loopback (v4 и v6), link-local (включая IMDS на 169.254.169.254 и форму IPv4-mapped-v6), RFC1918, IPv6 ULA, CGNAT и диапазон бенчмарков RFC2544. Вызов build_proxy_config(..., upstream_deny_cidrs=None) ОБЯЗАН выдать умолчание; отказаться можно только явным пустым списком.

Регрессия: test_default_deny_cidrs_present_when_unspecified, test_default_deny_includes_ipv4_mapped_v6.

Громкий отказ по логу аудита

ensure_audit_log поднимает RuntimeError на любой OSError. На закреплённой v0.39 демон этот файл не пишет вовсе (поля log.audit_path нет), поэтому cmd_setup считает сбой ПРЕДУПРЕЖДЕНИЕМ — до смены версии файл ничего не несёт — и помечает строку успеха как «зарезервировано». Когда закрепление переедет на версию с log.audit_path, к этому надо вернуться: предварительное создание станет несущим для гарантии 0o600 с первого байта, и мастер снова должен падать громко.

Ограничение схемы v0.39: поля log.audit_path в структуре config.Log у iron-proxy v0.39 нет, поэтому build_proxy_config принимает аргумент audit_log, но НЕ выводит его в отрендеренный yaml. Записи по запросам в v0.39 ложатся в iron-proxy.log вместе с событиями демона. Файл audit.log всё равно создаётся заранее с 0o600 и O_NOFOLLOW, чтобы контракт приватности сработал, когда закрепление переедет на версию с отдельным потоком.

Регрессия: test_ensure_audit_log_raises_on_immutable_parent, test_audit_log_kwarg_does_not_inject_audit_path_v039.

Громкий отказ в режиме Bitwarden

Когда credential_source: bitwarden И proxy.allow_env_fallback: false (умолчание):

  • Нет переменной с токеном доступа → cmd_start отказывает.
  • Нет project_id → cmd_start отказывает.
  • bws secret list не вернул значений для одного или нескольких сопоставленных провайдеров → _build_proxy_subprocess_env поднимает исключение.

Откат к окружению хоста в режиме BW возвращает ровно ту проблему устаревших значений, ради победы над которой этот режим и существует.

Регрессия: test_cmd_start_refuses_when_bitwarden_token_missing (уровень CLI); проверки строгого режима в _build_proxy_subprocess_env (уровень демона).

Обнаружение конфликтов docker_env

При enforce_on_docker: true переопределение в docker_env любой переменной, управляющей исходящим трафиком (HTTPS_PROXY, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS и прочих), ЛИБО любого сопоставленного real_env_name (OPENROUTER_API_KEY и других) поднимает RuntimeError ДО старта контейнера.

Регрессия: test_docker_env_collision_with_proxy_raises_when_enforce.

Защита от переиспользования PID

_pid_alive ОБЯЗАН свериться либо с _proxy_nonce в памяти процесса (случай одного процесса), либо с iron-proxy.nonce на диске (случай разных вызовов CLI), прежде чем доверять совпадению базового имени argv[0]. stop_proxy ОБЯЗАН перед SIGKILL перепроверить время старта из /proc/<pid>/stat и подавить сигнал, если оно сдвинулось.

Регрессия: test_stop_proxy_suppresses_sigkill_on_pid_recycle, test_pid_proc_starttime_parses_comm_with_parens, test_persisted_nonce_roundtrip.

Сохранение токенов при повторной настройке

merge_mappings(existing, discovered, rotate=False) ОБЯЗАН вернуть прежние токены для пересекающихся провайдеров. Повторный hermes egress setup не имеет права молча уронить работающие песочницы в 401. Осознанное согласие на это — флаг --rotate-tokens.

Регрессия: test_merge_mappings_preserves_existing_tokens, test_merge_mappings_rotate_mints_fresh_tokens.

Сохранение credential_source

cmd_setup НЕ ДОЛЖЕН понижать credential_source: bitwarden до env при повторном запуске без явного флага --no-bitwarden. Запуск hermes egress setup без флагов сохраняет то, что было настроено раньше.

Проверяется через сценарий cmd_setup в тестах CLI: путь сохранения bitwarden отрабатывается, когда за --from-bitwarden следует обычный повторный setup.

Точки расширения

Добавить провайдера с bearer-токеном

Словарь _BEARER_PROVIDERS в iron_proxy.py сопоставляет имя переменной окружения с кортежем вышестоящих хостов. Новая запись делает провайдера видимым для discover_provider_mappings(), и мастер выпустит для него токен автоматически, как только переменная появится в окружении.

_BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = {
    ...,
    "MY_PROVIDER_API_KEY": ("api.myprovider.com",),
}

Заодно обновите _DEFAULT_ALLOWED_HOSTS, чтобы прокси разрешал этот хост по умолчанию. Проверьте себя запуском test_discover_provider_mappings_*.

Добавить провайдера с токеном в заголовке (семейство x-api-key)

Если провайдер аутентифицируется статическим заголовком, отличным от Authorization, — как x-api-key у Anthropic, api-key у Azure или x-goog-api-key у Gemini, — добавьте его в _HEADER_AUTH_PROVIDERS: правило secrets.replace.match_headers в iron-proxy работает с произвольными именами заголовков, так что такие провайдеры подменяются полноценно.

_HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = {
    ...,
    "MY_PROVIDER_API_KEY": {
        "hosts": ("api.myprovider.com",),
        "match_headers": ("x-my-auth-header", "Authorization"),
        "aliases": (),
    },
}

Поле aliases используйте ТОЛЬКО для взаимозаменяемых имён переменных одних и тех же учётных данных (скажем, GOOGLE_API_KEY для GEMINI_API_KEY): такие имена схлопываются в одно сопоставление, потому что два правила с require: true на одном хосте отвергают запросы друг друга. И не забудьте про _DEFAULT_ALLOWED_HOSTS.

Добавить провайдера с подписью запроса (вне охвата)

Если провайдер работает через SigV4, OAuth от SDK или подписи запросов, статической подменой заголовка его не покрыть. Добавьте переменную в _NON_BEARER_PROVIDERS, чтобы мастер и hermes egress status предупреждали о ней:

_NON_BEARER_PROVIDERS: Tuple[str, ...] = (
    ...,
    "MY_SIGNED_PROVIDER_ACCESS_KEY",
)

Подключить iron-proxy к бэкенду помимо Docker

Функция _egress_proxy_args_for_docker заточена под Docker. Бэкенду, которому нужна такая же обвязка, понадобится свой аналог, который:

  1. Читает load_config().get("proxy", {}) и возвращает пустые аргументы, если enabled равно false.
  2. Вызывает iron_proxy.get_status() и отражает семантику enforce на путях отказа по configured, pid, listening и ca_cert_path.
  3. Вызывает iron_proxy.load_mappings() и отказывается монтировать что-либо, если список пуст И стоит enforce_on_docker: true.
  4. Выставляет семь переменных окружения (HTTPS_PROXY, NO_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, HERMES_EGRESS_PROXY) плюс по одной HERMES_PROXY_TOKEN_<NAME> на каждое сопоставление.
  5. Доставляет сертификат CA в песочницу по пути, которому рантайм будет доверять (обычно /etc/ssl/certs/hermes-egress-ca.crt).
  6. Реализует обнаружение конфликтов с пользовательской конфигурацией окружения для своего бэкенда.

Реализация под Docker занимает около 150 строк; для Modal, Daytona и SSH объём ожидается тот же.

Подписаться на события аудита по запросам

На закреплённой сейчас v0.39 iron-proxy пишет построчный JSON в ~/.hermes/proxy/iron-proxy.log, смешивая события демона и записи по запросам (см. «Логи в iron-proxy v0.39» в руководстве пользователя). Плагин или внешний наблюдатель может читать этот файл хвостом и реагировать на отказы по списку разрешённых, срабатывания подмены секретов и ошибки вышестоящих сервисов. Когда закрепление переедет на версию с log.audit_path, поток по запросам уйдёт в audit.log, и наблюдатели, настроенные на этот путь, заработают без участия оператора. Схема описана в docs.iron.sh/audit.

Тесты

# Hermetic suite (no network, no real binary)
scripts/run_tests.sh tests/agent/test_iron_proxy.py tests/hermes_cli/test_iron_proxy_cli.py

# Live E2E (real binary, real curl, real CONNECT tunnel)
HERMES_RUN_E2E=1 scripts/run_tests.sh tests/agent/test_iron_proxy_e2e.py

# Live PTY smoke against `hermes egress`
HERMES_HOME=$HOME/.hermes/cache/scratch/hermes-egress-test python3 -m hermes_cli.main egress --help
HERMES_HOME=$HOME/.hermes/cache/scratch/hermes-egress-test python3 -m hermes_cli.main egress setup --help

CLI построен на argparse, поэтому --help — хороший первый способ проверить, зарегистрировался ли ваш новый флаг.

Смотрите также

перевод и редактура:

обновлено

⌕ ESC