出口代理内部机制

本文从贡献者/插件作者的视角,介绍出口凭据注入防火墙(hermes egress / iron-proxy)的架构。终端用户的安装与使用文档见出口代理。

威胁模型与高层设计在用户页有概述;本文讲它如何接线、与安全相关的代码在哪,以及你若改动它必须保留哪些不变量。

模块布局

pm/security_packages.py              通过 PM 获取与发布锁定版本的二进制。
                                       发布来源暂存。

agent/proxy_sources/iron_proxy.py     核心:二进制查找、GPG 校验、CA 生成、配置构建、
                                       子进程生命周期、映射 I/O、PID/nonce
                                       防护。尽可能做成纯函数面。

hermes_cli/proxy_cli.py               向导 + 斜杠命令处理器。
                                       `hermes egress {install,setup,start,stop,
                                       status,disable,config}`。把核心模块
                                       接入 argparse。

hermes_cli/subcommands/egress.py:_dispatch_egress
                                       顶层子解析器分发器。
                                       dest='egress_command'(有意与入站 OAuth
                                       的 `hermes proxy` 子解析器错开,后者用
                                       dest='proxy_command')。

hermes_cli/config.py: proxy schema    DEFAULT_CONFIG 中的 `proxy:` 块。
                                       加一个旋钮意味着:在这里加、在
                                       proxy_cli.cmd_setup 中加向导提示或
                                       setdefault,并在用户指南页写文档。

tools/environments/docker.py
  _egress_proxy_args_for_docker()     构建 Docker 后端在 `proxy.enabled: true`
                                       时注入的 volume_args / env_overrides /
                                       host_args 三元组。

  DockerEnvironment.__init__          Docker 侧合并逻辑:与关键出口变量的冲突
                                       检测、经 _HERMES_EGRESS_NODE_OPTIONS_APPEND
                                       哨兵做 NODE_OPTIONS 追加合并、
                                       enforce_on_docker 优先级。

tests/agent/test_iron_proxy.py          密闭测试(约 70 个)。二进制安装
                                       路径、配置构建、映射 I/O、
                                       子进程生命周期、docker 参数构建、
                                       拒绝 CIDR 默认值、绑定策略、CA
                                       TOCTOU、ensure_audit_log 行为等。

tests/hermes_cli/test_iron_proxy_cli.py        CLI 处理器单测(约 20 个)。argparse
                                       接线、fail-loud 路径、BWS 刷新接线、
                                       dest='egress_command' 回归守护。

tests/agent/test_iron_proxy_e2e.py          实时 E2E(以 HERMES_RUN_E2E=1 门控)。
                                       真实 iron-proxy 二进制、真实 curl、
                                       端到端 token 交换已校验。

生命周期

hermes egress install
  -> agent.proxy_sources.iron_proxy.install_iron_proxy(force=...)
       pm.ensure("iron-proxy", explicit=True) 安装或修复条目。
       PM 对照 pm/lock.json 校验归档与来源哈希。
       包暂存校验发布校验和覆盖锁定的归档,
         然后调用 GPG 检查器。缺 GPG 时允许仅哈希安装。
         显式签名拒绝会中止安装。
       PM 发布条目并在 facts 中记录其身份与摘要。
       pm.installed_package("iron-proxy").binary 返回所选路径。
       _VERSION_CACHE.pop(target) 让下一次 status 调用重新探测 --version。

hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens]
  -> proxy_cli.cmd_setup
       步骤 1. find_iron_proxy(install_if_missing=False) -> 缺失则安装。
       步骤 2. ensure_ca_cert()
                 经子进程运行 openssl genrsa + req。
                 用 os.open(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW, 0o600)
                   + os.replace 写 CA key。在默认 umask 下它从不出现在磁盘上。
                 以 0o644 写 CA 证书(公开)。
       步骤 3. discover_provider_mappings(),或在 --from-bitwarden 时经
                 fetch_bitwarden_secrets() 从 BWS 拉取名称。
                 merge_mappings(existing=load_mappings(), discovered,
                                rotate=args.rotate_tokens) 保留先前的
                 token,除非传了 --rotate-tokens。
                 discover_uncovered_providers() 并发出告警。
       步骤 4. ensure_audit_log(audit_log_path)   # OSError 时抛错
               build_proxy_config(...) 在调用点应用默认值
                 (拒绝 CIDR 默认值、来自 _default_http_listen 的绑定策略)。
               write_proxy_config(cfg)            # 经 .tmp + os.replace 原子写,0o600
               write_mappings(mappings)           # 原子写,0o600
       步骤 5. proxy_cfg["enabled"] = True;credential_source 保留逻辑
              (重跑时绝不静默把 bitwarden 降级为 env);
               save_config(cfg)。

hermes egress start
  -> proxy_cli.cmd_start
       预检(拒绝启动路径):
         - credential_source=bitwarden? -> 预校验 access_token_env + project_id
       -> iron_proxy.start_proxy(
            refresh_secrets_from_bitwarden=...,
            bitwarden_config=...,
          )
            existing=_read_pid();若存活则幂等返回。
            _build_proxy_subprocess_env(...):白名单 + 映射的真实 env 名,
              剥去 HTTPS_PROXY 等以防递归,可选 BWS 刷新
             (缺值时抛错,除非 allow_env_fallback=true)。
            植入 nonce:_proxy_nonce = sha256(urandom(16));env[NONCE_ENV] = ...
            经 O_NOFOLLOW + 0o600 + st_uid 校验打开 log_path。
            Popen,stdin=DEVNULL、stdout=log_fd、stderr=STDOUT、
              start_new_session=True(POSIX)。
            在 finally 中关闭父进程的 log_fd。
            _write_pidfile_safely(pidfile, proc.pid)
              O_EXCL + O_NOFOLLOW + uid 校验 + 持久 nonce sidecar。
              FileExistsError -> 区分存活与过期,过期则重试一次。
            安装 SIGINT/SIGTERM 处理器(仅主线程)。
            轮询循环(do-while 形态):
              while True:
                if proc.poll() is not None: 拖日志 + 删 pidfile + 抛错
                if _port_listening(probe_host, tunnel_port): break  # probe_host = 配置的绑定 host
                if time.time() >= deadline: break  (do-while:在首次探测之后检查)
                time.sleep(0.1)
            退出时若未监听:_kill_and_wait(proc) + 删 pidfile + 抛错。

hermes egress stop
  -> iron_proxy.stop_proxy
       _read_pid + _pid_alive 守护。
       starttime_before = _pid_proc_starttime(pid)   # 仅 Linux;其他为 None
       os.kill(pid, SIGTERM)
       等待最多 5s 优雅退出。
       宽限期后:重新检查 starttime + _pid_alive。
         若已回收(starttime 漂移 或 _pid_alive False),绝不 SIGKILL。
         否则 os.kill(pid, _KILL_SIGNAL)。
       _cleanup_state_files:删 pidfile + nonce 兄弟文件。

安全不变量

这些是承重属性。你若改动该模块,必须保留它们。有回归测试的地方会给出测试名。

文件系统权限

路径模式测试
~/.hermes/proxy/(目录)0o700test_proxy_state_dir_is_0o700
ca.key0o600test_ca_key_created_with_0o600
ca.crt0o644(隐式;ensure_ca_cert 中的 chmod 调用)
proxy.yaml0o600(write_proxy_config 原子重命名后 chmod)
mappings.json0o600(write_mappings 原子重命名后 chmod)
iron-proxy.pid0o600(_write_pidfile_safely 中的 os.open(..., 0o600) 模式)
iron-proxy.nonce0o600(_write_pidfile_safely 中的 os.open(..., 0o600) 模式)
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 窗口。

子进程 env 最小化

_build_proxy_subprocess_env 绝不能用 os.environ.copy()。白名单是 _PROXY_SUBPROCESS_ENV_ALLOWLIST(PATH、HOME、locale 等)加上 load_mappings() 引用的 env 名。其余一切留在宿主机上。

回归:test_subprocess_env_strips_unrelated_secrets、test_subprocess_env_strips_proxy_recursion_vars、test_subprocess_env_keeps_infrastructure_vars。

绑定策略

_default_http_listen 返回单元素列表:在 Linux 上是 docker 网桥网关 IP(容器经 host.docker.internal:host-gateway 访问代理,它解析到网桥网关——回环绑定在容器内不可达);在 macOS/Windows Docker Desktop 上是回环(VPNkit 把 host.docker.internal 路由到宿主机)。无可检测 docker0 网桥的 Linux 回退到回环并告警。绝不 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。PATH 上一个恶意的 ip shim 无法注入 0.0.0.0。

v0.39 schema 约束与监听器角色(已对照二进制实测验证): 二进制的 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,都在平台绑定 host 上。Docker 后端把 HTTPS_PROXY 设为 tunnel_port、HTTP_PROXY 设为 tunnel_port + 1。

活性探针(start_proxy 轮询循环、get_status)经 _read_http_listen_from_config() 读取配置的绑定 host 并探测那个 host——硬编码回环探针会把一个健康的网桥绑定守护进程报为已死。

回归:test_default_bind_is_loopback_not_zero_zero(断言无 INADDR_ANY 且渲染的 yaml 中没有 http_listens)、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(对 8 个攻击输入参数化)。

指标端口冲突

metrics.listen 在 iron-proxy v0.39 中默认为 :9090——与 Hermes 默认的 tunnel_port: 9090 同一端口。build_proxy_config 必须显式固定 metrics.listen: 127.0.0.1:0,让指标绑定拿到一个临时回环端口,无论运维选择什么 tunnel_port 都永不与代理监听器冲突。

回归:test_metrics_listener_pinned_to_loopback_ephemeral。

默认拒绝 CIDR

_DEFAULT_UPSTREAM_DENY_CIDRS 覆盖回环(v4 + v6)、链路本地(含 169.254.169.254 的 IMDS 及其 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。

审计日志 fail-loud

ensure_audit_log 在任何 OSError 上抛 RuntimeError。在锁定的 v0.39 上守护进程从不写这个文件(没有 log.audit_path 字段),因此 cmd_setup 把该失败当 WARNING(在版本升级前该文件不承重),并把成功行限定为"reserved"。当锁定版本升到带 log.audit_path 的版本时,重新审视:预创建将成为"从第一字节起 0o600"保证的承重项,向导应再次 fail-loud。

v0.39 schema 约束: log.audit_path 不在 iron-proxy v0.39 的 config.Log 结构中,因此 build_proxy_config 接受 audit_log kwarg 但不把它写进渲染的 yaml。v0.39 上的逐请求记录落在 iron-proxy.log,与守护进程级事件并列。audit.log 仍以 O_NOFOLLOW、0o600 预创建,以便锁定版本升到支持独立流时隐私契约仍然成立。

回归:test_ensure_audit_log_raises_on_immutable_parent、test_audit_log_kwarg_does_not_inject_audit_path_v039。

Bitwarden 模式 fail-loud

当 credential_source: bitwarden 且 proxy.allow_env_fallback: false(默认):

  • 缺访问 token env 变量 -> cmd_start 拒绝。
  • 缺 project_id -> cmd_start 拒绝。
  • bws secret list 对一个或多个映射 provider 不返回值 -> _build_proxy_subprocess_env 抛错。

在 BW 模式下回退到宿主机 env,会重新引入 BW 路径本要击败的那个陈旧 bug。

回归:test_cmd_start_refuses_when_bitwarden_token_missing(CLI 层);_build_proxy_subprocess_env 中的严格模式断言(守护进程层)。

docker_env 冲突检测

当 enforce_on_docker: true,任何控制出口的变量(HTTPS_PROXY、SSL_CERT_FILE、NODE_EXTRA_CA_CERTS 等)或任何映射的 real_env_name(OPENROUTER_API_KEY 等)上的 docker_env 覆盖,都会在容器启动之前抛 RuntimeError。

回归:test_docker_env_collision_with_proxy_raises_when_enforce。

PID 回收防护

_pid_alive 在信任 argv[0] 基名匹配之前,必须查阅进程内 _proxy_nonce(同进程情形)或磁盘上的 iron-proxy.nonce(跨 CLI 情形)。stop_proxy 在 SIGKILL 之前必须重新检查 /proc/<pid>/stat starttime,并在 starttime 漂移时抑制该信号。

回归:test_stop_proxy_suppresses_sigkill_on_pid_recycle、test_pid_proc_starttime_parses_comm_with_parens、test_persisted_nonce_roundtrip。

重新 setup 时保留 token

merge_mappings(existing, discovered, rotate=False) 对重叠的 provider 必须返回先前的 token。重跑 hermes egress setup 不能静默地让运行中的沙箱 401。--rotate-tokens 是显式选择。

回归:test_merge_mappings_preserves_existing_tokens、test_merge_mappings_rotate_mints_fresh_tokens。

保留 credential_source

cmd_setup 在重跑时、若无显式 --no-bitwarden 标志,绝不能把 credential_source: bitwarden 降级为 env。运行 hermes egress setup(无标志)会保留之前配置的一切。

经 CLI 测试中的 cmd_setup 流程验证(先 --from-bitwarden、再一次普通 setup 重跑时会走到 bitwarden 保留路径)。

扩展点

新增一个 bearer-token provider

iron_proxy.py 中的 _BEARER_PROVIDERS 映射 env 变量名 -> 上游主机元组。加一条目即可被 discover_provider_mappings() 发现;当该 env 变量存在时,向导会自动为它签发 token。

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

同时更新 _DEFAULT_ALLOWED_HOSTS,让代理默认放行该上游。运行 test_discover_provider_mappings_* 确认。

新增一个 header-token provider(x-api-key 家族)

若 provider 用静态的非 Authorization 头认证(如 Anthropic 的 x-api-key、Azure 的 api-key、Gemini 的 x-goog-api-key),把它加入 _HEADER_AUTH_PROVIDERS——iron-proxy 的 secrets.replace.match_headers 针对任意头名,因此这些是一等的被交换 provider:

_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 仅用于同一凭据的可互换 env 变量名(如 GEMINI_API_KEY 的 GOOGLE_API_KEY)——别名会折叠为单一映射,因为同一 host 上两条 require: true 规则会互相拒绝请求。同时更新 _DEFAULT_ALLOWED_HOSTS。

新增一个签名认证 provider(未覆盖)

若 provider 使用 SigV4 / SDK 签发的 OAuth / 请求签名,静态头交换覆盖不了。把 env 变量加入 _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 为假则返回空参数。
  2. 调 iron_proxy.get_status();在 configured / pid / listening / ca_cert_path 失败路径上体现 enforce 语义。
  3. 调 iron_proxy.load_mappings();若为空且 enforce_on_docker: true 则拒绝挂载。
  4. 设置七个 env 变量(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. 实现针对用户后端专属 env 配置的冲突检测。

Docker 实现约 150 行;Modal / Daytona / SSH 预期类似量级。

订阅逐请求审计事件

iron-proxy 在当前锁定的 v0.39 上把行分隔 JSON 写到 ~/.hermes/proxy/iron-proxy.log(守护进程 + 逐请求记录合并;见用户指南中 "Logging on iron-proxy v0.39")。插件/外部监视器可以 tail 该文件,对白名单拒绝、机密交换或上游错误做出反应。当锁定版本升到支持 log.audit_path 的版本时,逐请求流会移到 audit.log,接在该路径上的监视器无需运维动作即可生效。schema 见 docs.iron.sh/audit(链接)。

测试

# 密闭套件(无网络、无真实二进制)
scripts/run_tests.sh tests/agent/test_iron_proxy.py tests/hermes_cli/test_iron_proxy_cli.py

# 实时 E2E(真实二进制、真实 curl、真实 CONNECT 隧道)
HERMES_RUN_E2E=1 scripts/run_tests.sh tests/agent/test_iron_proxy_e2e.py

# 针对 `hermes egress` 的实时 PTY 冒烟
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 是"我的新标志是否正确注册"的好探针。

另见