出口代理内部机制
本文从贡献者/插件作者的视角,介绍出口凭据注入防火墙(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/(目录) | 0o700 | test_proxy_state_dir_is_0o700 |
ca.key | 0o600 | test_ca_key_created_with_0o600 |
ca.crt | 0o644 | (隐式;ensure_ca_cert 中的 chmod 调用) |
proxy.yaml | 0o600 | (write_proxy_config 原子重命名后 chmod) |
mappings.json | 0o600 | (write_mappings 原子重命名后 chmod) |
iron-proxy.pid | 0o600 | (_write_pidfile_safely 中的 os.open(..., 0o600) 模式) |
iron-proxy.nonce | 0o600 | (_write_pidfile_safely 中的 os.open(..., 0o600) 模式) |
audit.log | 0o600 | test_ensure_audit_log_creates_with_0o600 |
iron-proxy.log | 0o600 | (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 专用的。想要类似接线的后端需要自己的对应物,它要:
- 读
load_config().get("proxy", {});若enabled为假则返回空参数。 - 调
iron_proxy.get_status();在configured/pid/listening/ca_cert_path失败路径上体现enforce语义。 - 调
iron_proxy.load_mappings();若为空且enforce_on_docker: true则拒绝挂载。 - 设置七个 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>变量。 - 把 CA 证书分发到沙箱内运行时会信任的路径(通常
/etc/ssl/certs/hermes-egress-ca.crt)。 - 实现针对用户后端专属 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 是"我的新标志是否正确注册"的好探针。
另见
- 用户侧安装 + 排障:出口代理
- Docker 后端内部机制:Docker
- Bitwarden Secrets Manager 集成:
hermes secrets bitwarden - CLI 命令参考:
hermes egress - 沙箱注入的环境变量:出口代理(沙箱注入)