托管路由 HTTP 代理入口(tp-*)Implementation Plan
托管路由 HTTP 代理入口(tp-*)Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让用户把 https://tp-<name>.<domain>:443 直接当作标准 HTTP 代理使用,出口 agent、Basic 认证与源 IP ACL 全部由管理后台配置的托管路由决定,无需在用户机器上安装 tunnelmesh-client。
Architecture: OpenResty 在既有 443 上新增一个 tp-* server 块,用 server 级 access_by_lua_block 接管 CONNECT(打过 proxy_connect 补丁的内核会对 CONNECT 跳过 location 匹配,因此不能用 content_by_lua_block),把请求原样搬到 tunnelmesh-server 进程内新增的明文内部监听 127.0.0.1:8089,并注入 X-TunnelMesh-Route(来自 SNI)与 X-TunnelMesh-Client-IP(来自 $remote_addr)。Server 是唯一策略执行点:身份解析、源 ACL、Basic 认证、目标校验、限额、审计、指标,然后经既有 relay.NodeTransport.OpenStream 把流量送到路由指定的 agent 出口,集群模式下自动跨节点。Lua 不含任何策略。
Tech Stack: Go 标准库(net/http、crypto/tls 不涉及、net/netip 不用,统一 net.IP)、既有 internal/relay、internal/routing、internal/storage、internal/observability;OpenResty(ngx_http_lua_module + 已安装的 ngx_http_proxy_connect_module 补丁内核);Vue 3 + TypeScript + Element Plus;Prometheus/Grafana 既有单 Dashboard。
Spec: docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md
Global Constraints
- Schema 保持
v13,本计划不新增迁移;migrations/ddl.sql与migrations/incremental/不得改动。 - 路由存储复用
tunnels表:protocol = "http-proxy"、domain = tp-<name>.<domain_suffix>、path_prefix = "/"、agent_id为出口 agent、target_host = "*"、target_port = 0(哨兵,导出常量storage.ProxyTargetWildcard = "*")。 - 凭据复用
credentials表,新增credential_type = "proxy_basic":public_key存 username,fingerprint = sha256(username)的 hex,加密 secret 存{"password":"..."}。 - 内部入口默认
server.proxy_entry.listen = 127.0.0.1:8089、trusted_proxies = ["127.0.0.1/32","::1/128"];peer 不在白名单时在读请求之前直接关闭连接。 - 可信头名称固定:
X-TunnelMesh-Route、X-TunnelMesh-Client-IP、X-TunnelMesh-Client-Port;client IP 缺失或非法一律 403,绝不回退到 peer IP。 - 默认值:
connect_timeout = 10s、idle_timeout = 300s、shutdown_timeout = 30s、max_concurrent_tunnels = 512、max_header_bytes = 16384、auth_backoff_threshold = 5;退避起始 30s,每次翻倍,上限 15m。 - 稳定错误码(响应体
{"code":<http>,"msg":"<错误码>","data":null}):proxy_route_identity_invalid、proxy_route_unavailable、proxy_source_denied、proxy_auth_required、proxy_auth_failed、proxy_auth_backoff、proxy_target_denied、proxy_target_invalid、proxy_egress_unavailable、proxy_egress_timeout、proxy_capacity_exhausted、credential_secret_unavailable。 - 未知路由、停用路由与 ACL 拒绝对外表现一致(403 + 同一文案),只有日志与指标区分 reason。
- 认证失败响应 407 必须带
Proxy-Authenticate: Basic realm="TunnelMesh", charset="UTF-8";容量超限响应 503 必须带Retry-After: 5。 - 日志、审计、指标不得包含密码、完整
Proxy-Authorization头或响应体。 - CONNECT 使用
relay.StreamRequest.Protocol = "tcp"(与internal/client/socks5_forward.go:250的裸隧道语义一致);绝对形式使用Protocol = "http",转发方式与既有internal/server/http_proxy.go的ServeRoute完全一致:upstreamReq.Write(stream)+http.ReadResponse(bufio.NewReader(stream), upstreamReq)+copyResponse(已核实ServeRoute不使用http.Client;WebSocket 升级走handleUpgrade的Hijack()+io.Copy;streamNetConn是预留适配器,当前在internal/下没有任何构造点,两条分支都不经过它,本计划不引用它)。 - OpenResty 的
tp-*server 块禁止http2 on,只协商 http/1.1;Lua 侧所有 cosocket 超时显式设置。 - 分层保持 Handler -> Service -> Repository;管理 API 前缀
/api/v1,响应{code,msg,data},列表用 cursor 分页。 - 每个任务先红后绿(TDD),完成后运行本任务列出的验证命令。
- 未经用户在当前任务中明确授权,不得执行
git commit/git push;步骤中的 commit 只在已获授权时执行。 - 本计划基线为
main@b3acd34(此前另一会话的大规模暂存改动已在6202b5b/b3acd34落盘,当前工作树除本计划与 spec 外干净)。开始 Task 0 前先用git log --oneline -1与git status --short复核基线;若 HEAD 已前进,必须重新核对本计划里所有以internal/server/api.go:NNNN形式给出的行号锚点(行号会随其它提交漂移,函数名与代码片段才是稳定锚点)。 - 无论工作树是否干净,执行本计划时只
git add本任务**Files:**列出的文件,禁止git add -A、git commit -a、git stash、git reset:同一仓库可能随时出现其它会话的改动,全量暂存会把无关变更混进本 PR。
File Structure
| 文件 | 动作 | 职责 |
|---|---|---|
internal/proxyentry/route.go |
Create | Route 快照结构、RouteSource 接口、认证模式常量 |
internal/proxyentry/identity.go |
Create | RouteKey、RouteIdentity 接口、HeaderRouteIdentity、SNIRouteIdentity(预留) |
internal/proxyentry/acl.go |
Create | 源 IP ACL 解析与匹配、ParseClientIP |
internal/proxyentry/auth.go |
Create | Basic 认证、SecretResolver 接口、失败退避 |
internal/proxyentry/target.go |
Create | 目标 host:port 校验(危险地址恒拒、路由 allowlist、私网开关) |
internal/proxyentry/errors.go |
Create | 稳定错误码与 Error 类型(HTTP 状态 + code 字符串) |
internal/server/proxy_entry.go |
Create | ProxyEntry handler:CONNECT 与绝对形式转发、限额、指标、审计 |
internal/server/proxy_entry_listener.go |
Create | 内部监听生命周期、trusted peer 前置校验、优雅退出 |
internal/server/proxy_entry_routes.go |
Create | 从 ManagedRouteTable 快照构建 proxyentry.RouteSource |
internal/server/runtime.go |
Modify | ServerRuntime 增加 proxyEntry 字段;仅在 server.proxy_entry.enabled 时构造,按 relay listener 同一模式启动并汇入 errCh,shutdown 时一并取消(Task 9) |
internal/server/managed_route_handler.go |
Modify | loadManagedRoutes 跳过 http-proxy 行;同一次查询构建 proxy 索引 |
internal/storage/models.go |
Modify | CredentialTypeProxyBasic 常量、ProxyTargetWildcard 常量 |
internal/storage/credential_repository.go |
Modify | 类型 switch 增加 proxy_basic 分支 |
internal/server/credential_service.go |
Modify | validateCredentialShape 支持 proxy_basic;username/password 落位 |
internal/server/credential_api.go |
Modify | credentialRequest.Username / credentialResponse.Username 透传(响应永不回显 secret);credentialErrorStatus(err) 从 writeCredentialError 抽出复用(Task 8 / Task 12) |
internal/server/api.go |
Modify | /api/v1/routes 支持 protocol=http-proxy、config 字段、proxyURL |
internal/config/config.go |
Modify | ProxyEntryConfig 结构、默认值、允许键、validateProxyEntry |
internal/cli/root.go |
Modify | rootOptions 字段、flag 绑定、values[...] 回填 |
internal/observability/metrics.go |
Modify | 5 个 proxy entry 指标 + 访问器 + 注册 |
web/src/api/client.ts |
Modify | ManagedRoute/ManagedRouteCreateInput 新字段、proxyURL |
web/src/views/Routes.vue |
Modify | 类型选择、ACL 编辑、凭据选择、使用说明抽屉、列表新列 |
web/src/views/Credentials.vue |
Modify | proxy_basic 类型选项与 username 字段 |
web/src/i18n/messages/zh-CN.ts / en-US.ts |
Modify | routes.*、credentials.* 新键(schema.ts 由 zh-CN 推导,无需改) |
deploy/openresty/tunnelmesh_proxy_entry.lua |
Create | CONNECT 搬运与 splice(无策略) |
deploy/openresty/tunnelmesh-proxy.conf.example |
Create | tp-* server 块模板(7 个占位符,渲染步骤见 Task 14/15) |
deploy/openresty/spike-connect-check.sh |
Create | 诊断脚本:验证目标 OpenResty 的补丁内核能否把 CONNECT 交给 server 级 access_by_lua;部署前置检查复用 |
deploy/openresty/Dockerfile.proxy-connect |
Create | 带 proxy_connect 补丁的 OpenResty 镜像:E2E 冒烟用,也可供没有该内核的主机部署 |
deploy/openresty/README.md |
Create | 四个产物的用途、占位符与渲染标记、构建与校验命令 |
deploy/openresty/openresty_artifacts_test.go |
Create | 产物一致性测试:Lua/conf 与 server.proxy_entry 默认值的契约、Dockerfile 版本与构建顺序 |
test/e2e/proxy-entry/run.mjs |
Create | 冒烟场景(8 项断言):需 TM_PROXY_E2E_NGINX=1 与 docker,否则打印原因 skip |
test/e2e/proxy-entry/lib/harness.mjs |
Create | 渲染仓库产物、生成自签证书、拉起 OpenResty 容器、等待监听 |
test/e2e/proxy-entry/lib/stub.mjs |
Create | 内部入口替身:按 CONNECT 目标编排 200/403/407/503 与回声 |
test/e2e/proxy-entry/README.md |
Create | 断言表、前置条件、环境变量、skip 语义与排障入口 |
docs/deployment/openresty-proxy-entry.md |
Create | 部署、验证、容量、reload 影响、回滚 |
docs/user-guide/http-proxy-entry.md |
Create | 管理员与用户双侧使用说明 |
docs/pull-requests/2026-09-13-managed-route-http-proxy-entry.md |
Create | PR 描述记录(章节对齐既有 PR 文档) |
internal/storage/repository.go |
不改(已核实) | tunnelRepo.Create 只补 ID/时间戳后 INSERT,不校验 target_host/target_port;DDL 对 tunnels 无 CHECK 约束,哨兵 "*"/0 直接可写,因此存储层无需特例 |
web/src/api/credentials.ts |
Modify | CredentialType 增加 proxy_basic、CredentialInput.username、Credential.username |
web/src/tests/routes.spec.ts / credentials-view.spec.ts / i18n.spec.ts |
Modify | 表单校验、类型切换、列表列、i18n 键集合对齐回归 |
web/src/tests/layout-overflow.spec.ts |
不改(自动守卫) | 该用例自动扫描 src/views/*.vue,Task 13 的新容器必须自行满足 grid-template-columns 与不引入整页横向滚动条,无需修改测试本身 |
internal/server/web_dist |
Modify | Task 13 Step 5 npm run build + scripts/verify-web-embed.sh 同步的 Go embed 产物 |
deploy/grafana/dashboards/tunnelmesh.json |
Modify | 追加第 6 个 Row HTTP Proxy Entry 与 7 个面板 |
deploy/grafana/dashboard_schema_test.go |
Modify | Row 数量 5 -> 6、新 Row 标题、5 个必需指标 |
deploy/prometheus/alert-rules.yaml |
Modify | 代理入口认证失败与拒绝率两条告警 |
deploy/README.md |
Modify | openresty/ 产物行、模板约定、Row 数量、校验命令 |
docs/operations/observability.md |
Modify | Row 结构、route/reason 标签基数、告警责任 |
docs/operations/completeness-checklist.md |
Modify | 已完成项追加 tp-* 代理入口 |
docs/deployment/nginx.md、docs/operations/configuration.md、docs/operations/troubleshooting.md、docs/api/openapi.yaml、docs/README.md |
Modify | 同步文档 |
docs/user-guide/managed-http-route.md |
Modify | 交叉引用:http-proxy 不是反代路由 |
docs/development/testing.md、docs/development/README.md |
Modify | 验证层级表与索引补 proxy-entry 端到端冒烟 |
docs/architecture/overview.md |
Modify | tp-* 入口拓扑链路(文字版)与“不新增公网端口、唯一策略执行点是 Server”的说明,spec 第 16 节要求 |
docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md |
Modify | 实现期偏差回写:Task 13 Step 6(活跃隧道数改为指向 Grafana 面板)、Task 14 Step 15(容器与 stub 修正)、Task 15 Step 15(b)(第 13 / 14 / 15 节) |
docs/superpowers/plans/2026-09-13-managed-route-http-proxy-entry-implementation.md |
Modify | 本计划自身:Task 0 Step 5 在文件末尾追加 ## Task 0 验证记录(spike 命令、实际输出与“继续 A3 / 回退 A2”结论) |
README.md、README.zh-CN.md |
Modify | 能力清单、Edge 部署链接、用户指南索引 |
Go 测试文件(internal/proxyentry/{route,identity,acl,auth,target,errors}_test.go、internal/server/{proxy_entry,proxy_entry_absolute,proxy_entry_listener,proxy_entry_routes,api_proxy_route,credential_api,credential_service}_test.go、internal/storage/credential_repository_test.go、internal/config/config_test.go、internal/observability/metrics_proxy_entry_test.go) |
Create/Modify | TDD 红灯测试;精确清单见各任务的 **Files:** 列表,此处只为文件总览完整性列出 |
docs/pull-requests/README.md、docs/superpowers/plans/README.md、docs/superpowers/specs/README.md、docs/architecture/adr/README.md |
Regenerate | python3 scripts/gen_doc_index.py 生成的索引,Task 15 Step 12 统一重生成并验证幂等,不手工编辑 |
Task 0: OpenResty CONNECT 可行性 spike(阻塞后续所有任务)
Files:
- Create:
deploy/openresty/spike-connect-check.sh(诊断脚本:只读nginx -V并在mktemp -d目录里起一个最小 server,不改任何生产配置;Task 15 的部署文档把它列为前置检查命令) - Modify:
docs/superpowers/plans/2026-09-13-managed-route-http-proxy-entry-implementation.md(在文件末尾追加## Task 0 验证记录小节)
Interfaces:
- Consumes: 目标机上已安装的 OpenResty(含 ngx_http_proxy_connect_module 补丁内核)
-
Produces: 一份五问五答的验证记录,以及“继续 A3 / 回退 A2”的决策
- Step 1: 写诊断脚本
#!/usr/bin/env bash
# Diagnostic for the tp-* HTTP proxy entry.
#
# 写于 Task 0 的可行性 spike,之后保留为部署前置检查:它回答“这台机器的
# OpenResty 到底能不能把 CONNECT 交给 server 级 access_by_lua”。
# 只读取 nginx 构建信息并在临时目录起一个最小 server,不修改任何生产配置。
set -euo pipefail
PORT="${1:-18443}"
WORKDIR="$(mktemp -d)"
echo "== (a) build flags =="
nginx -V 2>&1 | tr ' ' '\n' | grep -i "proxy_connect\|add-module\|with-http_ssl" || true
cat > "$WORKDIR/lua.lua" <<'LUA'
if ngx.req.get_method() ~= "CONNECT" then return end
ngx.log(ngx.WARN, "spike connect_host=", tostring(ngx.var.connect_host),
" connect_port=", tostring(ngx.var.connect_port),
" sni=", tostring(ngx.var.ssl_server_name))
local sock = ngx.req.socket(true)
if not sock then ngx.log(ngx.ERR, "spike: no raw socket") return ngx.exit(500) end
sock:send("HTTP/1.1 200 Connection Established\r\n\r\n")
sock:send("spike-ok\r\n")
return ngx.exit(444)
LUA
cat > "$WORKDIR/nginx.conf" <<CONF
events {}
http {
error_log $WORKDIR/error.log warn;
server {
listen 127.0.0.1:$PORT;
lua_check_client_abort on;
access_by_lua_file $WORKDIR/lua.lua;
location / { return 403 "non-connect"; }
}
}
CONF
echo "== (b)(c)(d) plain-text CONNECT through access_by_lua =="
nginx -p "$WORKDIR" -c "$WORKDIR/nginx.conf"
sleep 1
printf 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n' \
| timeout 5 nc 127.0.0.1 "$PORT" || true
sleep 1
echo "== error.log =="
cat "$WORKDIR/error.log"
nginx -p "$WORKDIR" -c "$WORKDIR/nginx.conf" -s quit || true
echo "workdir kept for inspection: $WORKDIR"
- Step 2: 在目标 OpenResty 主机上运行
Run: bash deploy/openresty/spike-connect-check.sh 18443
Expected: 输出包含 spike connect_host=example.com connect_port=443,且 nc 侧收到 HTTP/1.1 200 Connection Established 与 spike-ok。
- Step 3: 验证 TLS + SNI 与 h2 行为
在目标主机用现有通配证书复制一个 listen 18443 ssl;(不写 http2)的临时 server 块,server_name ~^tp-[a-z0-9-]+\.<suffix>$;,然后运行:
curl -sv --proxy-insecure -x https://tp-spike.<suffix>:18443 https://example.com 2>&1 | head -30
Expected: error.log 中 sni=tp-spike.<suffix>;curl 报告代理返回 200 后尝试与 example.com 建 TLS(会因为拿到 spike-ok 而失败,这是预期的)。同时确认 curl 协商的是 HTTP/1.1 而不是 h2。
- Step 4: 验证 (e) 非 CONNECT 分支的 hop-by-hop 头
Run: curl -sv -x http://tp-spike.<suffix>:18443 http://example.com/ -H 'Proxy-Authorization: Basic dTpw' 2>&1 | head -20,并在 location / 中临时 proxy_pass 到 nc -l 18080,观察 nc 收到的原始请求头。
Expected: 若 nc 侧看不到 Proxy-Authorization,则确认必须显式 proxy_set_header Proxy-Authorization $http_proxy_authorization;(本计划已按“必须”编写)。
- Step 5: 记录结论并做决策
在本计划文件末尾追加 ## Task 0 验证记录,逐条粘贴 (a)-(e) 的命令与实际输出,并写明结论行:决策:继续 A3 或 决策:回退 A2(原因:…)。
中止判据:若 Step 2 的 access_by_lua 收不到 CONNECT,或 Step 3 拿不到 raw socket / SNI,则停止本计划,按 spec 第 15 节回退 A2,并把决策写回 spec 第 17 节。
- Step 6: Commit(需授权)
git add deploy/openresty/spike-connect-check.sh docs/superpowers/plans/2026-09-13-managed-route-http-proxy-entry-implementation.md
git commit -m "test(deploy): add openresty connect diagnostic and spike record"
脚本必须提交而不是留在工作区:仓库当前有另一个会话的大规模暂存改动,未跟踪文件很容易被误扫进别人的 commit;而且它是部署前置检查的唯一可执行证据,运维需要能直接跑。这与 spec 第 15 节“spike 产物不进入生产代码”不冲突——它是只读诊断工具,不参与请求处理,Task 15 Step 15 会把这一条偏差写回 spec。
Task 1: 配置项 server.proxy_entry
Files:
- Modify:
internal/config/config.go(ServerConfig结构、默认值表、允许键表、Validate调用、新增validateProxyEntry) - Modify:
internal/config/config_test.go - Modify:
internal/cli/root.go(rootOptions字段、flag 绑定、values[...]回填) - Modify:
docs/operations/configuration.md
Interfaces:
- Consumes: 既有
config.Load/config.Validate/validateWebSSH的写法 - Produces:
type ProxyEntryConfig struct {
Enabled bool `mapstructure:"enabled" json:"enabled" yaml:"enabled"`
Listen string `mapstructure:"listen" json:"listen" yaml:"listen"`
TrustedProxies []string `mapstructure:"trusted_proxies" json:"trusted_proxies" yaml:"trusted_proxies"`
DomainSuffix string `mapstructure:"domain_suffix" json:"domain_suffix" yaml:"domain_suffix"`
RouteHeader string `mapstructure:"route_header" json:"route_header" yaml:"route_header"`
ClientIPHeader string `mapstructure:"client_ip_header" json:"client_ip_header" yaml:"client_ip_header"`
ClientPortHeader string `mapstructure:"client_port_header" json:"client_port_header" yaml:"client_port_header"`
ConnectTimeout time.Duration `mapstructure:"connect_timeout" json:"connect_timeout" yaml:"connect_timeout"`
IdleTimeout time.Duration `mapstructure:"idle_timeout" json:"idle_timeout" yaml:"idle_timeout"`
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout" json:"shutdown_timeout" yaml:"shutdown_timeout"`
MaxConcurrentTunnels int `mapstructure:"max_concurrent_tunnels" json:"max_concurrent_tunnels" yaml:"max_concurrent_tunnels"`
MaxHeaderBytes int `mapstructure:"max_header_bytes" json:"max_header_bytes" yaml:"max_header_bytes"`
AuthBackoffThreshold int `mapstructure:"auth_backoff_threshold" json:"auth_backoff_threshold" yaml:"auth_backoff_threshold"`
}
ServerConfig 增加字段 ProxyEntry ProxyEntryConfig \mapstructure:”proxy_entry” json:”proxy_entry” yaml:”proxy_entry”``。
- Step 1: 写失败测试
在 internal/config/config_test.go 追加:
func TestProxyEntryDefaultsAndFlagOverride(t *testing.T) {
cfg, err := config.Load(context.Background(), config.ConfigOptions{})
if err != nil {
t.Fatalf("load defaults: %v", err)
}
if cfg.Server.ProxyEntry.Enabled {
t.Fatal("proxy entry must default to disabled")
}
if cfg.Server.ProxyEntry.Listen != "127.0.0.1:8089" {
t.Fatalf("listen default = %q", cfg.Server.ProxyEntry.Listen)
}
if got := cfg.Server.ProxyEntry.TrustedProxies; len(got) != 2 || got[0] != "127.0.0.1/32" || got[1] != "::1/128" {
t.Fatalf("trusted proxies default = %v", got)
}
if cfg.Server.ProxyEntry.RouteHeader != "X-TunnelMesh-Route" ||
cfg.Server.ProxyEntry.ClientIPHeader != "X-TunnelMesh-Client-IP" ||
cfg.Server.ProxyEntry.ClientPortHeader != "X-TunnelMesh-Client-Port" {
t.Fatal("trusted header defaults changed")
}
if cfg.Server.ProxyEntry.ConnectTimeout != 10*time.Second ||
cfg.Server.ProxyEntry.IdleTimeout != 300*time.Second ||
cfg.Server.ProxyEntry.ShutdownTimeout != 30*time.Second {
t.Fatal("timeout defaults changed")
}
if cfg.Server.ProxyEntry.MaxConcurrentTunnels != 512 ||
cfg.Server.ProxyEntry.MaxHeaderBytes != 16384 ||
cfg.Server.ProxyEntry.AuthBackoffThreshold != 5 {
t.Fatal("limit defaults changed")
}
}
func TestValidateProxyEntryRejectsUnsafeCombinations(t *testing.T) {
base := func() config.Config {
cfg, err := config.Load(context.Background(), config.ConfigOptions{})
if err != nil {
t.Fatalf("load: %v", err)
}
cfg.Server.ProxyEntry.Enabled = true
cfg.Server.ProxyEntry.DomainSuffix = "tm.example.com"
return cfg
}
ok := base()
if err := config.Validate(ok); err != nil {
t.Fatalf("valid config rejected: %v", err)
}
missingSuffix := base()
missingSuffix.Server.ProxyEntry.DomainSuffix = ""
if err := config.Validate(missingSuffix); err == nil {
t.Fatal("enabled proxy entry requires domain_suffix")
}
badCIDR := base()
badCIDR.Server.ProxyEntry.TrustedProxies = []string{"10.0.0.0/33"}
if err := config.Validate(badCIDR); err == nil {
t.Fatal("invalid trusted proxy CIDR must fail")
}
wildcardTrust := base()
wildcardTrust.Server.ProxyEntry.Listen = "0.0.0.0:8089"
wildcardTrust.Server.ProxyEntry.TrustedProxies = []string{"0.0.0.0/0"}
if err := config.Validate(wildcardTrust); err == nil {
t.Fatal("non-loopback listen must not trust 0.0.0.0/0")
}
}
-
两个用例都用
context.Background()而不是t.Context():go.mod声明go 1.23,而t.Context()是 Go 1.24 才加入的 API,go vet的 stdversion 分析器会把它判成门禁失败;internal/config/config_test.go现有用例也一律用context.Background()。 -
Step 2: 运行测试确认失败
Run: go test ./internal/config/ -run 'TestProxyEntry|TestValidateProxyEntry' -count=1
Expected: 编译失败,cfg.Server.ProxyEntry undefined。
- Step 3: 最小实现
在 internal/config/config.go 中:新增 ProxyEntryConfig 结构(见 Interfaces)、在 ServerConfig 中加 ProxyEntry 字段、在默认值表中加入:
"server.proxy_entry.enabled": false,
"server.proxy_entry.listen": "127.0.0.1:8089",
"server.proxy_entry.trusted_proxies": []string{"127.0.0.1/32", "::1/128"},
"server.proxy_entry.domain_suffix": "",
"server.proxy_entry.route_header": "X-TunnelMesh-Route",
"server.proxy_entry.client_ip_header": "X-TunnelMesh-Client-IP",
"server.proxy_entry.client_port_header": "X-TunnelMesh-Client-Port",
"server.proxy_entry.connect_timeout": 10 * time.Second,
"server.proxy_entry.idle_timeout": 300 * time.Second,
"server.proxy_entry.shutdown_timeout": 30 * time.Second,
"server.proxy_entry.max_concurrent_tunnels": 512,
"server.proxy_entry.max_header_bytes": 16384,
"server.proxy_entry.auth_backoff_threshold": 5,
在允许键表中加入同样 13 个键,并按 validateWebSSH 的写法新增:
func validateProxyEntry(cfg ProxyEntryConfig) []string {
var problems []string
if !cfg.Enabled {
return nil
}
host, port, err := net.SplitHostPort(strings.TrimSpace(cfg.Listen))
if err != nil || port == "" {
problems = append(problems, "proxy entry listen must be a host:port value")
}
if strings.TrimSpace(cfg.DomainSuffix) == "" {
problems = append(problems, "proxy entry requires domain_suffix when enabled")
} else if !isDomainSuffix(cfg.DomainSuffix) {
problems = append(problems, "proxy entry domain_suffix must be a valid domain")
}
if len(cfg.TrustedProxies) == 0 {
problems = append(problems, "proxy entry requires at least one trusted proxy CIDR")
}
for _, cidr := range cfg.TrustedProxies {
if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
problems = append(problems, "proxy entry trusted proxy "+cidr+" is not a valid CIDR")
}
}
// A non-loopback listener is reachable from the network, so a wildcard
// trust list would let any host forge the route and client-IP headers.
if host != "127.0.0.1" && host != "localhost" && host != "::1" && host != "" {
for _, cidr := range cfg.TrustedProxies {
trimmed := strings.TrimSpace(cidr)
if trimmed == "0.0.0.0/0" || trimmed == "::/0" {
problems = append(problems, "proxy entry trusted_proxies must not be 0.0.0.0/0 or ::/0 when listen is not loopback")
}
}
}
if cfg.ConnectTimeout <= 0 || cfg.IdleTimeout <= 0 || cfg.ShutdownTimeout < 0 {
problems = append(problems, "proxy entry timeouts must be positive (shutdown_timeout may be zero)")
}
if cfg.MaxConcurrentTunnels < 0 || cfg.MaxHeaderBytes <= 0 || cfg.AuthBackoffThreshold <= 0 {
problems = append(problems, "proxy entry limits must be positive (max_concurrent_tunnels may be zero)")
}
return problems
}
isDomainSuffix 若仓库已有等价实现则复用,否则在本文件中按 net + 字符白名单实现(不允许通配符与前导点)。在 Validate 中按既有顺序追加 problems = append(problems, validateProxyEntry(cfg.Server.ProxyEntry)...)。
- Step 4: 运行测试确认通过
Run: go test ./internal/config/ -count=1 && go vet ./internal/config/
Expected: PASS。
- Step 5: 绑定命令行参数
在 internal/cli/root.go 的 rootOptions 中加字段(proxyEntryEnabled bool、proxyEntryListen string、proxyEntryDomainSuffix string、proxyEntryConnectTimeout time.Duration、proxyEntryIdleTimeout time.Duration、proxyEntryMaxConcurrentTunnels int),按既有 webssh 的三处写法各加一段:
flags.BoolVar(&opts.proxyEntryEnabled, "server.proxy_entry.enabled", false, "enable the tp-* managed HTTP proxy entry")
flags.StringVar(&opts.proxyEntryListen, "server.proxy_entry.listen", "", "internal plaintext listener for the proxy entry")
flags.StringVar(&opts.proxyEntryDomainSuffix, "server.proxy_entry.domain_suffix", "", "domain suffix for tp-* proxy routes")
flags.DurationVar(&opts.proxyEntryConnectTimeout, "server.proxy_entry.connect_timeout", 0, "proxy entry stream open timeout")
flags.DurationVar(&opts.proxyEntryIdleTimeout, "server.proxy_entry.idle_timeout", 0, "proxy entry tunnel idle timeout")
flags.IntVar(&opts.proxyEntryMaxConcurrentTunnels, "server.proxy_entry.max_concurrent_tunnels", 0, "maximum concurrent proxy tunnels, 0 means unlimited")
if flags.Changed("server.proxy_entry.enabled") {
values["server.proxy_entry.enabled"] = opts.proxyEntryEnabled
}
if flags.Changed("server.proxy_entry.listen") {
values["server.proxy_entry.listen"] = opts.proxyEntryListen
}
if flags.Changed("server.proxy_entry.domain_suffix") {
values["server.proxy_entry.domain_suffix"] = opts.proxyEntryDomainSuffix
}
if flags.Changed("server.proxy_entry.connect_timeout") {
values["server.proxy_entry.connect_timeout"] = opts.proxyEntryConnectTimeout
}
if flags.Changed("server.proxy_entry.idle_timeout") {
values["server.proxy_entry.idle_timeout"] = opts.proxyEntryIdleTimeout
}
if flags.Changed("server.proxy_entry.max_concurrent_tunnels") {
values["server.proxy_entry.max_concurrent_tunnels"] = opts.proxyEntryMaxConcurrentTunnels
}
在 docs/operations/configuration.md 的 server 小节追加 13 个键的说明表(键名、默认值、含义、是否必填)。
- Step 6: 运行验证
Run: go test ./internal/config/ ./internal/cli/ -count=1 && go vet ./internal/config/ ./internal/cli/ && gofmt -l internal/config internal/cli
Expected: 全部通过,gofmt 无输出。
- Step 7: Commit(需授权)
git add internal/config/config.go internal/config/config_test.go internal/cli/root.go docs/operations/configuration.md
git commit -m "feat(config): add server proxy entry settings"
Task 2: proxyentry 错误模型与路由快照类型
Files:
- Create:
internal/proxyentry/errors.go - Create:
internal/proxyentry/errors_test.go - Create:
internal/proxyentry/route.go - Create:
internal/proxyentry/route_test.go
Interfaces:
- Consumes: 无(新包起点)
- Produces:
package proxyentry
const (
AuthModeNone = "none"
AuthModeBasic = "basic"
StatusActive = "active"
StatusDisabled = "disabled"
)
// Error pairs an HTTP status with the stable error code returned to clients.
type Error struct {
Status int
Code string
message string
}
func (e *Error) Error() string
func (e *Error) HTTPHeaders() map[string]string // 例如 407 的 Proxy-Authenticate
func NewError(status int, code, message string, headers ...map[string]string) *Error
```go
var (
ErrRouteIdentityInvalid = NewError(http.StatusForbidden, "proxy_route_identity_invalid", "proxy route identity is invalid")
ErrRouteUnavailable = NewError(http.StatusForbidden, "proxy_route_unavailable", "proxy route is unavailable")
ErrSourceDenied = NewError(http.StatusForbidden, "proxy_source_denied", "source address is not allowed by this proxy route")
ErrAuthRequired = NewError(http.StatusProxyAuthRequired, "proxy_auth_required", "proxy authentication required", proxyAuthenticateHeader())
ErrAuthFailed = NewError(http.StatusProxyAuthRequired, "proxy_auth_failed", "proxy authentication failed", proxyAuthenticateHeader())
ErrAuthBackoff = NewError(http.StatusProxyAuthRequired, "proxy_auth_backoff", "too many failed proxy authentication attempts", proxyAuthenticateHeader())
ErrTargetDenied = NewError(http.StatusForbidden, "proxy_target_denied", "target address is denied by policy")
ErrTargetInvalid = NewError(http.StatusBadRequest, "proxy_target_invalid", "target host or port is invalid")
ErrEgressUnavailable = NewError(http.StatusBadGateway, "proxy_egress_unavailable", "egress agent is unavailable")
ErrEgressTimeout = NewError(http.StatusGatewayTimeout, "proxy_egress_timeout", "egress agent did not answer in time")
ErrCapacityExhausted = NewError(http.StatusServiceUnavailable, "proxy_capacity_exhausted", "proxy tunnel capacity exhausted", map[string]string{"Retry-After": "5"})
ErrSecretUnavailable = NewError(http.StatusServiceUnavailable, "credential_secret_unavailable", "credential secret storage is unavailable")
)
// proxyAuthenticateHeader keeps the 407 challenge identical on every failure path.
func proxyAuthenticateHeader() map[string]string {
return map[string]string{"Proxy-Authenticate": `Basic realm="TunnelMesh", charset="UTF-8"`}
}
// Route is the policy-relevant snapshot of one tp-* managed route. type Route struct { ID string Domain string // lowercase full host, e.g. tp-demo.tm.example.com Name string // demo AgentID string AuthMode string CredentialID string SourceCIDRs []string TargetCIDRs []string TargetPorts []int AllowPrivateTargets bool MaxConcurrentTunnels int Status string }
func (r Route) Active() bool func (r Route) RequiresAuth() bool
// RouteSource is implemented by the server-side managed-route snapshot. type RouteSource interface { ProxyRoute(ctx context.Context, domain string) (Route, bool) }
- [ ] **Step 1: 写失败测试**
```go
package proxyentry_test
import (
"errors"
"net/http"
"testing"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
)
func TestErrorsCarryStableCodesAndStatus(t *testing.T) {
cases := []struct {
err error
status int
code string
}{
{proxyentry.ErrRouteIdentityInvalid, http.StatusForbidden, "proxy_route_identity_invalid"},
{proxyentry.ErrRouteUnavailable, http.StatusForbidden, "proxy_route_unavailable"},
{proxyentry.ErrSourceDenied, http.StatusForbidden, "proxy_source_denied"},
{proxyentry.ErrAuthRequired, http.StatusProxyAuthRequired, "proxy_auth_required"},
{proxyentry.ErrAuthFailed, http.StatusProxyAuthRequired, "proxy_auth_failed"},
{proxyentry.ErrAuthBackoff, http.StatusProxyAuthRequired, "proxy_auth_backoff"},
{proxyentry.ErrTargetDenied, http.StatusForbidden, "proxy_target_denied"},
{proxyentry.ErrTargetInvalid, http.StatusBadRequest, "proxy_target_invalid"},
{proxyentry.ErrEgressUnavailable, http.StatusBadGateway, "proxy_egress_unavailable"},
{proxyentry.ErrEgressTimeout, http.StatusGatewayTimeout, "proxy_egress_timeout"},
{proxyentry.ErrCapacityExhausted, http.StatusServiceUnavailable, "proxy_capacity_exhausted"},
{proxyentry.ErrSecretUnavailable, http.StatusServiceUnavailable, "credential_secret_unavailable"},
}
for _, tc := range cases {
var pe *proxyentry.Error
if !errors.As(tc.err, &pe) {
t.Fatalf("%v is not a proxyentry.Error", tc.err)
}
if pe.Status != tc.status || pe.Code != tc.code {
t.Fatalf("got %d/%s want %d/%s", pe.Status, pe.Code, tc.status, tc.code)
}
}
if got := (&proxyentry.Error{}).HTTPHeaders(); got != nil {
// zero value must not invent headers
t.Fatalf("empty error produced headers %v", got)
}
var authErr *proxyentry.Error
errors.As(proxyentry.ErrAuthFailed, &authErr)
if authErr.HTTPHeaders()["Proxy-Authenticate"] == "" {
t.Fatal("407 must advertise Proxy-Authenticate")
}
var capErr *proxyentry.Error
errors.As(proxyentry.ErrCapacityExhausted, &capErr)
if capErr.HTTPHeaders()["Retry-After"] != "5" {
t.Fatal("503 must carry Retry-After: 5")
}
}
func TestRouteActiveAndRequiresAuth(t *testing.T) {
active := proxyentry.Route{Status: proxyentry.StatusActive, AuthMode: proxyentry.AuthModeBasic}
if !active.Active() || !active.RequiresAuth() {
t.Fatal("active basic route must require auth")
}
if (proxyentry.Route{Status: proxyentry.StatusDisabled}).Active() {
t.Fatal("disabled route must not be active")
}
if (proxyentry.Route{Status: proxyentry.StatusActive, AuthMode: proxyentry.AuthModeNone}).RequiresAuth() {
t.Fatal("none mode must not require auth")
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/proxyentry/ -count=1
Expected: FAIL,package internal/proxyentry 不存在或符号未定义。
- Step 3: 最小实现
errors.go 用 *Error 结构承载 status/code/message/headers,导出变量用 NewError 构造;407 三个变量带 Proxy-Authenticate: Basic realm="TunnelMesh", charset="UTF-8",503 带 Retry-After: 5。route.go 定义 Route、Active()(Status == StatusActive)、RequiresAuth()(AuthMode == AuthModeBasic)与 RouteSource 接口。
- Step 4: 运行测试确认通过
Run: go test ./internal/proxyentry/ -count=1 && go vet ./internal/proxyentry/ && gofmt -l internal/proxyentry
Expected: PASS,无 vet/gofmt 输出。
- Step 5: Commit(需授权)
git add internal/proxyentry/errors.go internal/proxyentry/errors_test.go internal/proxyentry/route.go internal/proxyentry/route_test.go
git commit -m "feat(proxy): add entry error and route types"
Task 3: 路由身份解析(header 与 SNI 两种实现)
Files:
- Create:
internal/proxyentry/identity.go - Create:
internal/proxyentry/identity_test.go
Interfaces:
- Consumes: Task 2 的
ErrRouteIdentityInvalid - Produces:
// RouteKey is the normalized lowercase full proxy host, e.g. tp-demo.tm.example.com.
type RouteKey string
func (k RouteKey) Name() string // 去掉 tp- 前缀与域名后缀后的名称
type RouteIdentity interface {
Resolve(r *http.Request) (RouteKey, error)
}
func NewHeaderRouteIdentity(header, domainSuffix string) HeaderRouteIdentity
type HeaderRouteIdentity struct{ Header, DomainSuffix string }
func (h HeaderRouteIdentity) Resolve(r *http.Request) (RouteKey, error)
func NewSNIRouteIdentity(domainSuffix string) SNIRouteIdentity
type SNIRouteIdentity struct{ DomainSuffix string }
func (s SNIRouteIdentity) Resolve(r *http.Request) (RouteKey, error)
- Step 1: 写失败测试
package proxyentry_test
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
)
func TestHeaderRouteIdentityNormalizesAndRejects(t *testing.T) {
identity := proxyentry.NewHeaderRouteIdentity("X-TunnelMesh-Route", "tm.example.com")
cases := []struct {
values []string
want proxyentry.RouteKey
ok bool
}{
{[]string{"tp-demo.tm.example.com"}, "tp-demo.tm.example.com", true},
{[]string{"TP-Demo.TM.Example.COM"}, "tp-demo.tm.example.com", true},
{[]string{"demo"}, "tp-demo.tm.example.com", true},
{[]string{"tp-demo"}, "tp-demo.tm.example.com", true},
{[]string{" tp-demo "}, "tp-demo.tm.example.com", true},
{[]string{"tp-demo.other.com"}, "", false},
{[]string{"other.com"}, "", false},
{[]string{""}, "", false},
{[]string{"tp-.tm.example.com"}, "", false},
{[]string{"tp-de mo.tm.example.com"}, "", false},
{[]string{"tp-a.tm.example.com", "tp-b.tm.example.com"}, "", false},
{nil, "", false},
}
for _, tc := range cases {
r := httptest.NewRequest(http.MethodGet, "http://target.example/", nil)
r.Header.Del("X-TunnelMesh-Route")
for _, v := range tc.values {
r.Header.Add("X-TunnelMesh-Route", v)
}
got, err := identity.Resolve(r)
if tc.ok && (err != nil || got != tc.want) {
t.Fatalf("values=%q got %q err %v want %q", tc.values, got, err, tc.want)
}
if !tc.ok && err == nil {
t.Fatalf("values=%q must be rejected, got %q", tc.values, got)
}
}
}
func TestRouteKeyName(t *testing.T) {
if got := proxyentry.RouteKey("tp-demo.tm.example.com").Name(); got != "demo" {
t.Fatalf("Name() = %q", got)
}
}
func TestSNIRouteIdentityUsesTLSHandshake(t *testing.T) {
identity := proxyentry.NewSNIRouteIdentity("tm.example.com")
plain := httptest.NewRequest(http.MethodConnect, "https://target.example:443", nil)
if _, err := identity.Resolve(plain); err == nil {
t.Fatal("plaintext request must not resolve a route")
}
withSNI := httptest.NewRequest(http.MethodConnect, "https://target.example:443", nil)
withSNI.TLS = &tls.ConnectionState{ServerName: "TP-Demo.tm.example.com"}
got, err := identity.Resolve(withSNI)
if err != nil || got != "tp-demo.tm.example.com" {
t.Fatalf("got %q err %v", got, err)
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/proxyentry/ -run 'Identity|RouteKey' -count=1
Expected: FAIL,undefined: proxyentry.NewHeaderRouteIdentity。
- Step 3: 最小实现
package proxyentry
import (
"crypto/tls"
"net/http"
"regexp"
"strings"
)
var proxyRouteNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`)
type RouteKey string
func (k RouteKey) Name() string {
host := string(k)
host = strings.TrimPrefix(host, "tp-")
if idx := strings.IndexByte(host, '.'); idx > 0 {
host = host[:idx]
}
return host
}
type RouteIdentity interface {
Resolve(r *http.Request) (RouteKey, error)
}
// HeaderRouteIdentity trusts a header that only the OpenResty front end may
// set. The listener rejects untrusted peers before parsing, so this header is
// never attacker-controlled in a correctly deployed topology.
type HeaderRouteIdentity struct {
Header string
DomainSuffix string
}
func NewHeaderRouteIdentity(header, domainSuffix string) HeaderRouteIdentity {
return HeaderRouteIdentity{Header: header, DomainSuffix: strings.ToLower(strings.TrimSpace(domainSuffix))}
}
func (h HeaderRouteIdentity) Resolve(r *http.Request) (RouteKey, error) {
values := r.Header.Values(h.Header)
if len(values) != 1 {
return "", ErrRouteIdentityInvalid
}
return normalizeRouteKey(values[0], h.DomainSuffix)
}
// SNIRouteIdentity is the A2 fallback path: the Server terminates TLS and the
// route name comes from the handshake instead of a header.
type SNIRouteIdentity struct{ DomainSuffix string }
func NewSNIRouteIdentity(domainSuffix string) SNIRouteIdentity {
return SNIRouteIdentity{DomainSuffix: strings.ToLower(strings.TrimSpace(domainSuffix))}
}
func (s SNIRouteIdentity) Resolve(r *http.Request) (RouteKey, error) {
var state *tls.ConnectionState
if r.TLS != nil {
state = r.TLS
}
if state == nil || strings.TrimSpace(state.ServerName) == "" {
return "", ErrRouteIdentityInvalid
}
return normalizeRouteKey(state.ServerName, s.DomainSuffix)
}
// normalizeRouteKey accepts either the bare name, the name with the tp- prefix,
// or the full host, and always returns the lowercase full host.
func normalizeRouteKey(raw, suffix string) (RouteKey, error) {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" || suffix == "" {
return "", ErrRouteIdentityInvalid
}
name := strings.TrimPrefix(value, "tp-")
if strings.Contains(name, ".") {
if name == value {
// A full host was supplied: it must end with the configured suffix.
dot := strings.IndexByte(value, '.')
name = strings.TrimPrefix(value[:dot], "tp-")
if !strings.EqualFold(value[dot+1:], suffix) {
return "", ErrRouteIdentityInvalid
}
} else {
return "", ErrRouteIdentityInvalid
}
}
if !proxyRouteNamePattern.MatchString(name) {
return "", ErrRouteIdentityInvalid
}
return RouteKey("tp-" + name + "." + suffix), nil
}
- Step 4: 运行测试确认通过
Run: go test ./internal/proxyentry/ -count=1 && go vet ./internal/proxyentry/
Expected: PASS。
- Step 5: Commit(需授权)
git add internal/proxyentry/identity.go internal/proxyentry/identity_test.go
git commit -m "feat(proxy): resolve tp route identity"
Task 4: 源 IP ACL
Files:
- Create:
internal/proxyentry/acl.go - Create:
internal/proxyentry/acl_test.go
Interfaces:
- Consumes: Task 2 的
ErrSourceDenied - Produces:
type SourceACL struct{ /* unexported */ }
func NewSourceACL(cidrs []string) (SourceACL, error) // 写入路径使用:非法条目返回 error
func DenyAllSourceACL() SourceACL // 运行期构造失败时的 fail-closed 值
func (a SourceACL) Allow(ip net.IP) bool // 空 ACL 一律 false
func (a SourceACL) Broken() bool // 构造时含非法条目
func ParseClientIP(raw string) (net.IP, error) // 严格解析,拒绝 "ip:port" 与空串
func NormalizeCIDR(raw string) (string, error) // 单 IP 自动补 /32 或 /128
- Step 1: 写失败测试
package proxyentry_test
import (
"net"
"testing"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
)
func TestSourceACLMatching(t *testing.T) {
acl, err := proxyentry.NewSourceACL([]string{"11.71.85.0/24", "10.1.2.3", "2001:db8::/32"})
if err != nil {
t.Fatalf("NewSourceACL: %v", err)
}
allow := []string{"11.71.85.7", "10.1.2.3", "2001:db8::1"}
deny := []string{"11.71.86.1", "10.1.2.4", "2001:db9::1", ""}
for _, raw := range allow {
if !acl.Allow(net.ParseIP(raw)) {
t.Fatalf("%s should be allowed", raw)
}
}
for _, raw := range deny {
if acl.Allow(net.ParseIP(raw)) {
t.Fatalf("%s should be denied", raw)
}
}
if acl.Allow(nil) {
t.Fatal("nil IP must be denied")
}
}
func TestSourceACLDefaultsToDeny(t *testing.T) {
empty, err := proxyentry.NewSourceACL(nil)
if err != nil {
t.Fatalf("empty ACL must parse: %v", err)
}
if empty.Allow(net.ParseIP("8.8.8.8")) {
t.Fatal("empty ACL must deny everything")
}
wildcard, err := proxyentry.NewSourceACL([]string{"0.0.0.0/0", "::/0"})
if err != nil {
t.Fatalf("wildcard ACL must parse: %v", err)
}
if !wildcard.Allow(net.ParseIP("8.8.8.8")) || !wildcard.Allow(net.ParseIP("2001:db8::1")) {
t.Fatal("0.0.0.0/0 and ::/0 must allow everything")
}
}
func TestSourceACLRejectsInvalidEntries(t *testing.T) {
if _, err := proxyentry.NewSourceACL([]string{"10.0.0.0/33"}); err == nil {
t.Fatal("invalid CIDR must fail at write time")
}
if _, err := proxyentry.NewSourceACL([]string{"not-an-ip"}); err == nil {
t.Fatal("invalid literal must fail at write time")
}
broken := proxyentry.DenyAllSourceACL()
if !broken.Broken() || broken.Allow(net.ParseIP("8.8.8.8")) {
t.Fatal("deny-all ACL must report broken and deny")
}
}
func TestParseClientIP(t *testing.T) {
if ip, err := proxyentry.ParseClientIP(" 11.71.85.176 "); err != nil || ip.String() != "11.71.85.176" {
t.Fatalf("got %v err %v", ip, err)
}
for _, raw := range []string{"", "11.71.85.176:4321", "abc", "11.71.85.176, 10.0.0.1"} {
if _, err := proxyentry.ParseClientIP(raw); err == nil {
t.Fatalf("%q must be rejected", raw)
}
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/proxyentry/ -run 'SourceACL|ParseClientIP' -count=1
Expected: FAIL,undefined: proxyentry.NewSourceACL。
- Step 3: 最小实现
acl.go:内部字段 networks []*net.IPNet 与 broken bool。NormalizeCIDR 对不含 / 的输入按 net.ParseIP 结果补 /32(IPv4)或 /128(IPv6),再交给 net.ParseCIDR。NewSourceACL 遇到任一非法条目返回 error 且返回 DenyAllSourceACL() 的值。Allow 在 broken 或 len(networks)==0 或 ip == nil 时返回 false,否则对每个 network 调 Contains(先把 IPv4 规范化为 4 字节形式,避免 v4-in-v6 映射不匹配)。ParseClientIP 用 net.ParseIP(strings.TrimSpace(raw)),为 nil 时返回 error;显式拒绝含 : 且不是合法 IPv6 的 ip:port 形式与逗号列表。
- Step 4: 运行测试确认通过
Run: go test ./internal/proxyentry/ -count=1 && go vet ./internal/proxyentry/ && gofmt -l internal/proxyentry
Expected: PASS。
- Step 5: Commit(需授权)
git add internal/proxyentry/acl.go internal/proxyentry/acl_test.go
git commit -m "feat(proxy): add source IP allowlist"
Task 5: Basic 认证与失败退避
Files:
- Create:
internal/proxyentry/auth.go - Create:
internal/proxyentry/auth_test.go
Interfaces:
- Consumes: Task 2 的
Route、ErrAuthRequired、ErrAuthFailed、ErrAuthBackoff、ErrSecretUnavailable - Produces:
type CredentialSecret struct{ Username, Password string }
// SecretResolver is implemented by the server on top of storage.CredentialRepository
// plus auth.SecretStore. It never returns plaintext to callers outside this package.
type SecretResolver interface {
ProxyBasicSecret(ctx context.Context, credentialID string) (CredentialSecret, error)
}
// ErrSecretStoreUnavailable is returned by SecretResolver implementations when
// decryption cannot even be attempted (missing key, store failure).
var ErrSecretStoreUnavailable = errors.New("proxyentry: credential secret store unavailable")
type Authenticator struct{ /* unexported */ }
func NewAuthenticator(resolver SecretResolver, backoffThreshold int) *Authenticator
func (a *Authenticator) SetClock(now func() time.Time)
func (a *Authenticator) Authorize(ctx context.Context, route Route, clientIP net.IP, header string) error
func (a *Authenticator) Attempts(routeID, clientIP string) int // 测试与诊断用
- Step 1: 写失败测试
package proxyentry_test
import (
"context"
"encoding/base64"
"errors"
"net"
"testing"
"time"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
)
type stubSecrets struct {
secret proxyentry.CredentialSecret
err error
calls int
}
func (s *stubSecrets) ProxyBasicSecret(context.Context, string) (proxyentry.CredentialSecret, error) {
s.calls++
return s.secret, s.err
}
func basic(user, pass string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
}
func TestAuthenticatorModes(t *testing.T) {
secrets := &stubSecrets{secret: proxyentry.CredentialSecret{Username: "demo", Password: "s3cret"}}
auth := proxyentry.NewAuthenticator(secrets, 5)
route := proxyentry.Route{ID: "r1", AuthMode: proxyentry.AuthModeBasic, CredentialID: "c1"}
ip := net.ParseIP("11.71.85.7")
if err := auth.Authorize(context.Background(), route, ip, basic("demo", "s3cret")); err != nil {
t.Fatalf("valid credentials rejected: %v", err)
}
if err := auth.Authorize(context.Background(), proxyentry.Route{ID: "r2", AuthMode: proxyentry.AuthModeNone}, ip, ""); err != nil {
t.Fatalf("none mode must skip auth: %v", err)
}
if err := auth.Authorize(context.Background(), route, ip, ""); !errors.Is(err, proxyentry.ErrAuthRequired) {
t.Fatalf("missing header = %v", err)
}
if err := auth.Authorize(context.Background(), route, ip, "Bearer abc"); !errors.Is(err, proxyentry.ErrAuthRequired) {
t.Fatalf("wrong scheme = %v", err)
}
if err := auth.Authorize(context.Background(), route, ip, basic("demo", "wrong")); !errors.Is(err, proxyentry.ErrAuthFailed) {
t.Fatalf("wrong password = %v", err)
}
if err := auth.Authorize(context.Background(), route, ip, basic("DEMO", "s3cret")); !errors.Is(err, proxyentry.ErrAuthFailed) {
t.Fatalf("username must match exactly, got %v", err)
}
}
func TestAuthenticatorSecretStoreFailure(t *testing.T) {
secrets := &stubSecrets{err: proxyentry.ErrSecretStoreUnavailable}
auth := proxyentry.NewAuthenticator(secrets, 5)
route := proxyentry.Route{ID: "r1", AuthMode: proxyentry.AuthModeBasic, CredentialID: "c1"}
err := auth.Authorize(context.Background(), route, net.ParseIP("10.0.0.1"), basic("demo", "s3cret"))
if !errors.Is(err, proxyentry.ErrSecretUnavailable) {
t.Fatalf("got %v", err)
}
if auth.Attempts("r1", "10.0.0.1") != 0 {
t.Fatal("store failures must not count against the user")
}
}
func TestAuthenticatorBackoff(t *testing.T) {
secrets := &stubSecrets{secret: proxyentry.CredentialSecret{Username: "demo", Password: "s3cret"}}
auth := proxyentry.NewAuthenticator(secrets, 3)
now := time.Unix(1_800_000_000, 0)
auth.SetClock(func() time.Time { return now })
route := proxyentry.Route{ID: "r1", AuthMode: proxyentry.AuthModeBasic, CredentialID: "c1"}
ip := net.ParseIP("10.0.0.9")
for i := 0; i < 3; i++ {
if err := auth.Authorize(context.Background(), route, ip, basic("demo", "bad")); !errors.Is(err, proxyentry.ErrAuthFailed) {
t.Fatalf("attempt %d = %v", i, err)
}
}
callsBefore := secrets.calls
if err := auth.Authorize(context.Background(), route, ip, basic("demo", "s3cret")); !errors.Is(err, proxyentry.ErrAuthBackoff) {
t.Fatalf("after threshold got %v", err)
}
if secrets.calls != callsBefore {
t.Fatal("backoff must short-circuit before comparing passwords")
}
now = now.Add(31 * time.Second)
if err := auth.Authorize(context.Background(), route, ip, basic("demo", "s3cret")); err != nil {
t.Fatalf("after 30s backoff got %v", err)
}
if auth.Attempts("r1", "10.0.0.9") != 0 {
t.Fatal("success must reset the counter")
}
}
func TestAuthenticatorMissingCredentialID(t *testing.T) {
auth := proxyentry.NewAuthenticator(&stubSecrets{}, 5)
route := proxyentry.Route{ID: "r1", AuthMode: proxyentry.AuthModeBasic}
if err := auth.Authorize(context.Background(), route, net.ParseIP("10.0.0.1"), basic("demo", "x")); !errors.Is(err, proxyentry.ErrAuthFailed) {
t.Fatalf("missing credentialId = %v", err)
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/proxyentry/ -run Authenticator -count=1
Expected: FAIL,undefined: proxyentry.NewAuthenticator。
- Step 3: 最小实现
auth.go 内部:
type authAttempt struct {
failures int
until time.Time
}
type Authenticator struct {
resolver SecretResolver
threshold int
now func() time.Time
mu sync.Mutex
attempts map[string]*authAttempt
}
Authorize 顺序:route.RequiresAuth() 为假直接返回 nil;route.CredentialID == "" 记一次失败并返回 ErrAuthFailed;查退避(until.After(now) 返回 ErrAuthBackoff,且不调用 resolver);解析 header(必须 Basic 前缀 + 合法 base64 + 恰好一个 :,否则记失败并返回 ErrAuthRequired);调用 resolver.ProxyBasicSecret,errors.Is(err, ErrSecretStoreUnavailable) 返回 ErrSecretUnavailable 且不计数,其它 error 记失败并返回 ErrAuthFailed;用户名与密码分别用 sha256.Sum256 后 subtle.ConstantTimeCompare 比较,任一不匹配记失败并返回 ErrAuthFailed;成功则清空该 key 的计数并返回 nil。
退避时长:30 * time.Second << min(failures-threshold, 5),上限 15 * time.Minute。key 为 routeID + "|" + clientIP.String()。SetClock 仅用于测试;attempts map 在成功或过期后删除条目,避免无界增长(每次写入前顺带清理已过期的 key,单次最多扫描 64 条)。
- Step 4: 运行测试确认通过
Run: go test ./internal/proxyentry/ -count=1 && go test -race ./internal/proxyentry/ -count=1 && go vet ./internal/proxyentry/
Expected: PASS。
- Step 5: Commit(需授权)
git add internal/proxyentry/auth.go internal/proxyentry/auth_test.go
git commit -m "feat(proxy): verify basic auth with backoff"
Task 6: 目标地址校验
Files:
- Create:
internal/proxyentry/target.go - Create:
internal/proxyentry/target_test.go
Interfaces:
- Consumes:
routing.NewPolicy(cidrs []string, ports any) (routing.Policy, error)、routing.Policy.Validate(ip net.IP, port int) error、routing.IsDangerousAddress(ip net.IP) bool、Task 2 的ErrTargetDenied/ErrTargetInvalid - Produces:
type TargetPolicy struct{ /* unexported */ }
func NewTargetPolicy(cidrs []string, ports []int, allowPrivateTargets bool) (TargetPolicy, error)
func (p TargetPolicy) Validate(host string, port int) error // host 可为域名或 IP 字面量
func (p TargetPolicy) ValidateIP(ip net.IP, port int) error
func IsPrivateTarget(ip net.IP) bool
- Step 1: 写失败测试
package proxyentry_test
import (
"errors"
"net"
"testing"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
)
func TestTargetPolicyIPLiterals(t *testing.T) {
open, err := proxyentry.NewTargetPolicy(nil, nil, true)
if err != nil {
t.Fatalf("NewTargetPolicy: %v", err)
}
if err := open.Validate("93.184.216.34", 443); err != nil {
t.Fatalf("public target rejected: %v", err)
}
if err := open.Validate("10.0.0.5", 8080); err != nil {
t.Fatalf("private target must be allowed by default: %v", err)
}
if err := open.Validate("169.254.169.254", 80); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("metadata address = %v", err)
}
// routing.IsDangerousAddress deliberately keeps loopback reachable: the
// target is dialed from the Agent host, so 127.0.0.1 is a valid
// agent-local service address. Only multicast/reserved ranges are denied.
if err := open.Validate("127.0.0.1", 22); err != nil {
t.Fatalf("loopback must follow routing.Policy and stay allowed: %v", err)
}
if err := open.Validate("224.0.0.1", 80); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("multicast = %v", err)
}
if err := open.Validate("2001:db8::1", 443); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("IPv6 literal follows routing.Policy and is denied = %v", err)
}
if err := open.Validate("93.184.216.34", 0); !errors.Is(err, proxyentry.ErrTargetInvalid) {
t.Fatalf("port 0 = %v", err)
}
if err := open.Validate("93.184.216.34", 70000); !errors.Is(err, proxyentry.ErrTargetInvalid) {
t.Fatalf("port 70000 = %v", err)
}
if err := open.Validate("", 80); !errors.Is(err, proxyentry.ErrTargetInvalid) {
t.Fatalf("empty host = %v", err)
}
}
func TestTargetPolicyPrivateSwitchAndAllowlist(t *testing.T) {
strict, err := proxyentry.NewTargetPolicy(nil, nil, false)
if err != nil {
t.Fatalf("NewTargetPolicy: %v", err)
}
if err := strict.Validate("10.0.0.5", 80); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("private target with allowPrivateTargets=false = %v", err)
}
if err := strict.Validate("127.0.0.1", 22); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("loopback with allowPrivateTargets=false = %v", err)
}
allowlisted, err := proxyentry.NewTargetPolicy([]string{"10.10.0.0/16"}, []int{443, 8443}, true)
if err != nil {
t.Fatalf("NewTargetPolicy: %v", err)
}
if err := allowlisted.Validate("10.10.1.1", 443); err != nil {
t.Fatalf("allowlisted target rejected: %v", err)
}
if err := allowlisted.Validate("10.10.1.1", 80); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("port outside allowlist = %v", err)
}
if err := allowlisted.Validate("10.20.1.1", 443); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("CIDR outside allowlist = %v", err)
}
}
func TestTargetPolicyDefersDomainsToAgent(t *testing.T) {
policy, err := proxyentry.NewTargetPolicy([]string{"10.10.0.0/16"}, nil, true)
if err != nil {
t.Fatalf("NewTargetPolicy: %v", err)
}
// A CIDR allowlist cannot be evaluated before resolution, so domains are
// rejected here exactly like routing.Policy does for agent policies.
if err := policy.Validate("intranet.example.com", 443); !errors.Is(err, proxyentry.ErrTargetDenied) {
t.Fatalf("domain with CIDR allowlist = %v", err)
}
open, err := proxyentry.NewTargetPolicy(nil, nil, true)
if err != nil {
t.Fatalf("NewTargetPolicy: %v", err)
}
if err := open.Validate("intranet.example.com", 443); err != nil {
t.Fatalf("domain without allowlist must be deferred to the agent: %v", err)
}
for _, host := range []string{"bad host", "host/path", "a.b.c." + string(make([]byte, 250))} {
if err := open.Validate(host, 443); !errors.Is(err, proxyentry.ErrTargetInvalid) {
t.Fatalf("%q = %v", host, err)
}
}
}
func TestIsPrivateTarget(t *testing.T) {
for _, raw := range []string{"10.0.0.1", "192.168.1.1", "172.16.0.1", "127.0.0.1", "169.254.1.1", "fd00::1", "::1"} {
if !proxyentry.IsPrivateTarget(net.ParseIP(raw)) {
t.Fatalf("%s should count as private", raw)
}
}
if proxyentry.IsPrivateTarget(net.ParseIP("93.184.216.34")) {
t.Fatal("public address must not count as private")
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/proxyentry/ -run 'TargetPolicy|IsPrivateTarget' -count=1
Expected: FAIL,undefined: proxyentry.NewTargetPolicy。
- Step 3: 最小实现
target.go:
type TargetPolicy struct {
policy routing.Policy
allowPrivateTargets bool
restrictCIDRs bool
}
func NewTargetPolicy(cidrs []string, ports []int, allowPrivateTargets bool) (TargetPolicy, error) {
policy, err := routing.NewPolicy(cidrs, ports)
if err != nil {
return TargetPolicy{}, err
}
return TargetPolicy{policy: policy, allowPrivateTargets: allowPrivateTargets, restrictCIDRs: len(cidrs) > 0}, nil
}
func (p TargetPolicy) Validate(host string, port int) error {
if port < 1 || port > 65535 {
return ErrTargetInvalid
}
trimmed := strings.TrimSpace(host)
if trimmed == "" || len(trimmed) > 253 || strings.ContainsAny(trimmed, " /\t\r\n") {
return ErrTargetInvalid
}
if ip := net.ParseIP(trimmed); ip != nil {
return p.ValidateIP(ip, port)
}
if !validProxyHostname(trimmed) {
return ErrTargetInvalid
}
// Domains cannot be matched against a CIDR allowlist before resolution, so
// an allowlist behaves like the existing agent policy and denies them. The
// agent re-validates the resolved address before dialing.
if p.restrictCIDRs {
return ErrTargetDenied
}
return nil
}
func (p TargetPolicy) ValidateIP(ip net.IP, port int) error {
if ip == nil {
return ErrTargetInvalid
}
if routing.IsDangerousAddress(ip) {
return ErrTargetDenied
}
if !p.allowPrivateTargets && IsPrivateTarget(ip) {
return ErrTargetDenied
}
if err := p.policy.Validate(ip, port); err != nil {
return ErrTargetDenied
}
return nil
}
func IsPrivateTarget(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
validProxyHostname 用 regexp.MustCompile(^a-zA-Z0-9?$) 且要求不含 ..、不以 . 结尾。
注意:routing.Policy.Validate 现有实现对 ip.To4() == nil 返回 ErrDangerousAddress,即 IPv6 目标一律拒绝。本任务沿用该语义(agent 侧策略同样如此),不修改 internal/routing。源 ACL 的 IPv6 支持在 Task 4 中独立实现,两者不冲突。
- Step 4: 运行测试确认通过
Run: go test ./internal/proxyentry/ ./internal/routing/ -count=1 && go vet ./internal/proxyentry/
Expected: PASS。
- Step 5: Commit(需授权)
git add internal/proxyentry/target.go internal/proxyentry/target_test.go
git commit -m "feat(proxy): validate proxy target addresses"
Task 7: proxy 路由快照(与 HTTP 反代路由分流)
Files:
- Modify:
internal/storage/models.go(新增ProtocolHTTPProxy、ProxyTargetWildcard常量) - Modify:
internal/server/managed_route_handler.go(loadManagedRoutes显式跳过http-proxy;ManagedRouteTable增加 proxy 快照字段) - Create:
internal/server/proxy_entry_routes.go(loadProxyRoutes、ManagedRouteTable.ProxyRoute) - Create:
internal/server/proxy_entry_routes_test.go
Interfaces:
- Consumes:
storage.Open(ctx, storage.DriverSQLite, dsn, true)、db.Tunnels().Create/List、Task 2 的proxyentry.Route - Produces:
// storage 常量
const ProtocolHTTPProxy = "http-proxy"
const ProxyTargetWildcard = "*"
// ManagedRouteTable 新增方法(沿用既有 5s TTL 与 stale-on-error 策略)
func (t *ManagedRouteTable) ProxyRoute(ctx context.Context, domain string) (proxyentry.Route, bool)
// 新增包级函数
func loadProxyRoutes(ctx context.Context, db *storage.DB) ([]proxyentry.Route, error)
- Step 1: 写失败测试
package server
import (
"context"
"testing"
"time"
"github.com/tunnelmesh/tunnelmesh/internal/storage"
)
func createProxyRouteFixture(t *testing.T, db *storage.DB, id, domain, status, config string) {
t.Helper()
err := db.Tunnels().Create(context.Background(), storage.Tunnel{
ID: id, AgentID: "agent-" + id, Protocol: storage.ProtocolHTTPProxy, Domain: domain,
PathPrefix: "/", TargetHost: storage.ProxyTargetWildcard, TargetPort: 0, Status: status,
Config: config, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatal(err)
}
}
func TestLoadProxyRoutesDecodesPolicyConfig(t *testing.T) {
db, err := storage.Open(context.Background(), storage.DriverSQLite, "file:proxy-route-snapshot?mode=memory&cache=shared", true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
createProxyRouteFixture(t, db, "p1", "tp-demo.tm.example.com", "active",
`{"authMode":"basic","credentialId":"c1","sourceCIDRs":["11.71.85.0/24"],"targetCIDRs":["10.10.0.0/16"],"targetPorts":[443],"allowPrivateTargets":false,"maxConcurrentTunnels":7,"description":"demo"}`)
routes, err := loadProxyRoutes(context.Background(), db)
if err != nil {
t.Fatal(err)
}
if len(routes) != 1 {
t.Fatalf("routes = %#v", routes)
}
got := routes[0]
if got.ID != "p1" || got.Domain != "tp-demo.tm.example.com" || got.Name != "demo" || got.AgentID != "agent-p1" {
t.Fatalf("identity = %#v", got)
}
if got.AuthMode != "basic" || got.CredentialID != "c1" || got.AllowPrivateTargets || got.MaxConcurrentTunnels != 7 {
t.Fatalf("policy = %#v", got)
}
if len(got.SourceCIDRs) != 1 || got.SourceCIDRs[0] != "11.71.85.0/24" || len(got.TargetCIDRs) != 1 || len(got.TargetPorts) != 1 {
t.Fatalf("allowlists = %#v", got)
}
}
func TestLoadProxyRoutesDefaultsAndInvalidConfig(t *testing.T) {
db, err := storage.Open(context.Background(), storage.DriverSQLite, "file:proxy-route-defaults?mode=memory&cache=shared", true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
createProxyRouteFixture(t, db, "p2", "tp-plain.tm.example.com", "active", `{}`)
routes, err := loadProxyRoutes(context.Background(), db)
if err != nil {
t.Fatal(err)
}
if routes[0].AuthMode != "none" || !routes[0].AllowPrivateTargets || routes[0].MaxConcurrentTunnels != 0 {
t.Fatalf("defaults = %#v", routes[0])
}
createProxyRouteFixture(t, db, "p3", "tp-bad.tm.example.com", "active", `{"authMode":`)
if _, err := loadProxyRoutes(context.Background(), db); err == nil {
t.Fatal("invalid config JSON must fail the snapshot")
}
}
func TestProxyRoutesStayOutOfManagedHTTPRoutes(t *testing.T) {
db, err := storage.Open(context.Background(), storage.DriverSQLite, "file:proxy-route-isolation?mode=memory&cache=shared", true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
createManagedRouteFixture(t, db, "http-route", `{"targetScheme":"https"}`)
// A proxy route with a valid target port must still not leak into the
// reverse-proxy resolver.
err = db.Tunnels().Create(context.Background(), storage.Tunnel{
ID: "leak", AgentID: "agent-leak", Protocol: storage.ProtocolHTTPProxy,
Domain: "tp-leak.example.com", PathPrefix: "/", TargetHost: "10.0.0.9", TargetPort: 443,
Status: "active", Config: `{}`, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatal(err)
}
routes, err := loadManagedRoutes(context.Background(), db)
if err != nil {
t.Fatal(err)
}
if len(routes) != 1 || routes[0].ID != "http-route" {
t.Fatalf("managed HTTP routes = %#v", routes)
}
}
func TestManagedRouteTableProxyRouteLookupAndTTL(t *testing.T) {
db, err := storage.Open(context.Background(), storage.DriverSQLite, "file:proxy-route-table?mode=memory&cache=shared", true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
table := NewManagedRouteTable(db, "apps.example.com", 20*time.Millisecond)
if _, ok := table.ProxyRoute(context.Background(), "tp-demo.tm.example.com"); ok {
t.Fatal("unknown route must not resolve")
}
createProxyRouteFixture(t, db, "p1", "tp-demo.tm.example.com", "active", `{}`)
route, ok := table.ProxyRoute(context.Background(), "TP-DEMO.tm.example.com")
if !ok || route.ID != "p1" {
t.Fatalf("lookup = %v %v", ok, route)
}
// Disabled routes stay in the snapshot so the handler can answer 403 with
// the same shape as an unknown route.
createProxyRouteFixture(t, db, "p9", "tp-off.tm.example.com", "disabled", `{}`)
time.Sleep(40 * time.Millisecond)
off, ok := table.ProxyRoute(context.Background(), "tp-off.tm.example.com")
if !ok || off.Active() {
t.Fatalf("disabled route = %v %v", ok, off)
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/server/ -run 'ProxyRoute|LoadProxyRoutes' -count=1
Expected: FAIL,undefined: storage.ProtocolHTTPProxy、undefined: loadProxyRoutes。
- Step 3: 最小实现
internal/storage/models.go 在 CredentialType 常量附近加:
// ProtocolHTTPProxy marks a managed route that terminates a forward-proxy
// request instead of reverse-proxying a fixed target.
const ProtocolHTTPProxy = "http-proxy"
// ProxyTargetWildcard is the sentinel stored in tunnels.target_host for
// http-proxy routes, whose real target comes from each request.
const ProxyTargetWildcard = "*"
internal/server/managed_route_handler.go:在 loadManagedRoutes 的分页循环里,managedTunnelActive 判断之前加入
if tunnel.Protocol == storage.ProtocolHTTPProxy {
// Proxy entries are resolved by ManagedRouteTable.ProxyRoute; they must
// never reach the Host/path reverse-proxy resolver.
continue
}
并在 ManagedRouteTable 结构中增加 proxyRoutes map[string]proxyentry.Route 字段(由既有 mu 保护)。
internal/server/proxy_entry_routes.go:
package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"time"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
"github.com/tunnelmesh/tunnelmesh/internal/storage"
)
// ProxyRoute returns one tp-* route from the cached snapshot. Lookups are
// case-insensitive and refresh on the same TTL as the HTTP resolver.
func (t *ManagedRouteTable) ProxyRoute(ctx context.Context, domain string) (proxyentry.Route, bool) {
if t == nil {
return proxyentry.Route{}, false
}
key := strings.ToLower(strings.TrimSpace(domain))
if key == "" {
return proxyentry.Route{}, false
}
now := time.Now()
t.mu.RLock()
if t.proxyRoutes != nil && now.Sub(t.lastAttemptAt) < t.ttl {
route, ok := t.proxyRoutes[key]
t.mu.RUnlock()
return route, ok
}
t.mu.RUnlock()
t.mu.Lock()
defer t.mu.Unlock()
now = time.Now()
if t.proxyRoutes != nil && now.Sub(t.lastAttemptAt) < t.ttl {
route, ok := t.proxyRoutes[key]
return route, ok
}
routes, err := loadProxyRoutes(ctx, t.db)
if err != nil {
if t.proxyRoutes != nil {
slog.WarnContext(ctx, "proxy route refresh failed; using cached routes", "error", err.Error())
route, ok := t.proxyRoutes[key]
return route, ok
}
slog.WarnContext(ctx, "proxy route snapshot unavailable", "error", err.Error())
return proxyentry.Route{}, false
}
snapshot := make(map[string]proxyentry.Route, len(routes))
for _, route := range routes {
snapshot[route.Domain] = route
}
t.proxyRoutes = snapshot
t.loadedAt = now
route, ok := snapshot[key]
return route, ok
}
// loadProxyRoutes pages the same tunnels table used by managed HTTP routes and
// decodes the proxy-specific config JSON.
func loadProxyRoutes(ctx context.Context, db *storage.DB) ([]proxyentry.Route, error) {
if db == nil || db.Tunnels() == nil {
return nil, errors.New("proxy route repository unavailable")
}
var routes []proxyentry.Route
cursor := ""
seenCursors := make(map[string]struct{})
for {
page, err := db.Tunnels().List(ctx, cursor, managedRoutePageSize)
if err != nil {
return nil, err
}
for _, tunnel := range page.Items {
if tunnel.Protocol != storage.ProtocolHTTPProxy {
continue
}
if tunnel.Domain == "" || tunnel.AgentID == "" {
continue
}
var cfg struct {
AuthMode string `json:"authMode"`
CredentialID string `json:"credentialId"`
SourceCIDRs []string `json:"sourceCIDRs"`
TargetCIDRs []string `json:"targetCIDRs"`
TargetPorts []int `json:"targetPorts"`
AllowPrivateTargets *bool `json:"allowPrivateTargets"`
MaxConcurrentTunnels int `json:"maxConcurrentTunnels"`
}
cfg.AllowPrivateTargets = boolPtr(true)
if tunnel.Config != "" {
if err := json.Unmarshal([]byte(tunnel.Config), &cfg); err != nil {
return nil, errors.New("proxy route config is invalid: " + tunnel.ID)
}
}
authMode := strings.ToLower(strings.TrimSpace(cfg.AuthMode))
if authMode == "" {
authMode = proxyentry.AuthModeNone
}
domain := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(tunnel.Domain), "."))
routes = append(routes, proxyentry.Route{
ID: tunnel.ID, Domain: domain, Name: proxyentry.RouteKey(domain).Name(),
AgentID: tunnel.AgentID, AuthMode: authMode, CredentialID: strings.TrimSpace(cfg.CredentialID),
SourceCIDRs: cfg.SourceCIDRs, TargetCIDRs: cfg.TargetCIDRs, TargetPorts: cfg.TargetPorts,
AllowPrivateTargets: *cfg.AllowPrivateTargets, MaxConcurrentTunnels: cfg.MaxConcurrentTunnels,
Status: tunnel.Status,
})
}
if !page.HasMore {
return routes, nil
}
if page.NextCursor == "" || page.NextCursor == cursor {
return nil, errors.New("proxy route pagination did not advance")
}
if _, ok := seenCursors[page.NextCursor]; ok {
return nil, errors.New("proxy route pagination repeated a cursor")
}
seenCursors[page.NextCursor] = struct{}{}
cursor = page.NextCursor
}
}
func boolPtr(v bool) *bool { return &v }
- Step 4: 运行测试确认通过
Run: go test ./internal/server/ ./internal/storage/ -count=1 && go vet ./internal/server/ ./internal/storage/
Expected: PASS(含既有 managed route 测试不回归)。
- Step 5: Commit(需授权)
git add internal/storage/models.go internal/server/managed_route_handler.go internal/server/proxy_entry_routes.go internal/server/proxy_entry_routes_test.go
git commit -m "feat(server): snapshot tp proxy routes"
Task 8: 凭据类型 proxy_basic
Files:
- Modify:
internal/storage/models.go(CredentialTypeProxyBasic常量) - Modify:
internal/storage/credential_repository.go(validateCredential增加分支) - Modify:
internal/storage/credential_repository_test.go - Modify:
internal/server/credential_service.go(validateCredentialShape、Create、Update的 username/password 落位) - Modify:
internal/server/credential_service_test.go - Modify:
internal/server/credential_api.go(credentialRequest.Username/credentialResponse.Username透传,见 Task 12;credentialResponse.Username取credential.PublicKey,前端不必理解 username 复用该列的实现细节) - 不改:
internal/server/api.go(本任务只动凭据链路;/api/v1/routes的http-proxy校验分支与proxyURL由 Task 12 负责)
Interfaces:
- Consumes:
auth.SecretStore.Encrypt(secret string) ([]byte, []byte, string, int, error)、auth.SecretStore.Decrypt(ciphertext, nonce []byte, keyID string, version int) (string, error)、既有credentialSecretBlob - Produces:
const CredentialTypeProxyBasic CredentialType = "proxy_basic"
// CredentialService 新增:为 proxy_basic 读取 username 与 password。
func (s *CredentialService) ProxyBasicSecret(ctx context.Context, actor auth.Principal, id string) (username string, password string, err error)
CredentialInput 增加字段 Username string(proxy_basic 必填,写入 credential.PublicKey,Fingerprint = hex(sha256(username)))。
CredentialPatch 增加字段 Username *string(nil 表示不变;非 nil 时改写 credential.PublicKey 并重算 fingerprint)。Task 12 的 credential_api.go 依赖这两个字段,必须在本任务一并加上。
- Step 1: 写失败测试
在 internal/storage/credential_repository_test.go 追加:
func TestCredentialRepositoryAcceptsProxyBasic(t *testing.T) {
db, err := OpenSQLite(context.Background(), "file:credential-proxy-basic?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
created, err := db.Credentials().Create(context.Background(), Credential{
ID: "cred-proxy", OwnerUserID: "user-1", Name: "proxy demo",
Type: CredentialTypeProxyBasic, PublicKey: "demo", Fingerprint: "fp-demo",
SecretCiphertext: "ct", SecretNonce: "nonce", SecretKeyID: "key-1", SecretVersion: 1,
Enabled: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatalf("proxy_basic credential rejected: %v", err)
}
if !created.HasSecret() || created.Type != CredentialTypeProxyBasic {
t.Fatalf("created = %#v", created)
}
if _, err := db.Credentials().Create(context.Background(), Credential{
ID: "cred-bad", OwnerUserID: "user-1", Name: "no username",
Type: CredentialTypeProxyBasic, Enabled: true,
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
}); err == nil {
t.Fatal("proxy_basic without username must be rejected")
}
}
在 internal/server/credential_service_test.go 追加(沿用该文件既有的 newCredentialSecretFixture,它返回 (*storage.DB, *CredentialService) 且已注入 AES-GCM secret store):
func TestCredentialServiceCreatesProxyBasicWithEncryptedPassword(t *testing.T) {
db, service := newCredentialSecretFixture(t)
actor := auth.Principal{UserID: "admin-a", Username: "root", Role: "admin"}
created, err := service.Create(context.Background(), actor, CredentialInput{
Name: "proxy demo", Type: storage.CredentialTypeProxyBasic, Username: "demo",
Enabled: true, Secret: &CredentialSecret{Password: "s3cret"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.PublicKey != "demo" || created.Fingerprint == "" || !created.HasSecret() {
t.Fatalf("created = %#v", created)
}
username, password, err := service.ProxyBasicSecret(context.Background(), actor, created.ID)
if err != nil {
t.Fatalf("ProxyBasicSecret: %v", err)
}
if username != "demo" || password != "s3cret" {
t.Fatalf("secret = %q/%q", username, password)
}
if _, _, err := service.Create(context.Background(), actor, CredentialInput{
Name: "missing password", Type: storage.CredentialTypeProxyBasic, Username: "demo", Enabled: true,
}); err == nil {
t.Fatal("proxy_basic requires a password")
}
if _, _, err := service.Create(context.Background(), actor, CredentialInput{
Name: "missing username", Type: storage.CredentialTypeProxyBasic, Enabled: true,
Secret: &CredentialSecret{Password: "s3cret"},
}); err == nil {
t.Fatal("proxy_basic requires a username")
}
_ = db
}
哨兵 target 的 HTTP 层校验(apiService.CreateTunnel 与 API.createTunnel 的必填分支)连同 /api/v1/routes 的端到端断言一起放在 Task 12,避免同一个文件被两个任务反复改写。
- Step 2: 运行测试确认失败
Run: go test ./internal/storage/ -run ProxyBasic -count=1; go test ./internal/server/ -run ProxyBasic -count=1
Expected: FAIL,undefined: CredentialTypeProxyBasic、service.ProxyBasicSecret undefined。
- Step 3: 最小实现
internal/storage/models.go:
// CredentialTypeProxyBasic stores an HTTP proxy username/password pair. The
// username lives in PublicKey so list views can show it; the password only
// exists inside the encrypted secret blob.
const CredentialTypeProxyBasic CredentialType = "proxy_basic"
internal/storage/credential_repository.go 的 validateCredential switch 增加:
case CredentialTypeProxyBasic:
if strings.TrimSpace(credential.PublicKey) == "" || strings.TrimSpace(credential.Fingerprint) == "" {
return errors.New("credential username and fingerprint are required")
}
if credential.SecretCiphertext == "" {
return errors.New("proxy credential requires an encrypted password")
}
internal/server/credential_service.go:CredentialInput 加 Username string;validateCredentialShape 的 switch 增加 case storage.CredentialTypeProxyBasic(要求 PublicKey 非空、secret.Password 非空或已有 stored secret);Create 中对 proxy_basic 设置 credential.PublicKey = strings.TrimSpace(input.Username) 与 credential.Fingerprint = hex(sha256(username));Update 中禁止把 proxy_basic 的 PublicKey 清空。新增:
// ProxyBasicSecret decrypts one proxy credential. It is the only path that
// turns the stored blob back into a password, and it never logs the value.
func (s *CredentialService) ProxyBasicSecret(ctx context.Context, actor auth.Principal, id string) (string, string, error) {
credential, err := s.Get(ctx, actor, id)
if err != nil {
return "", "", err
}
if credential.Type != storage.CredentialTypeProxyBasic || !credential.Enabled || credential.DeletedAt != nil {
return "", "", ErrCredentialInvalid
}
blob, err := s.decryptSecret(credential)
if err != nil {
return "", "", err
}
return credential.PublicKey, blob.Password, nil
}
decryptSecret 若尚不存在,则从既有 WebSSH 密码读取路径抽出同一函数(auth.SecretStore.Decrypt + json.Unmarshal 到 credentialSecretBlob),并在 secret store 为 nil 或解密失败时返回可被 errors.Is(err, ErrCredentialSecretUnavailable) 判定的错误。
本任务不改 internal/server/api.go 的路由校验分支。internal/server/credential_api.go 中:credentialRequest 增加 Username string \json:”username”` 并透传到 CredentialInput.Username;credentialResponse 增加 Username string `json:”username,omitempty”`,取值来自 credential.PublicKey(前端不必理解 username 复用 PublicKey 列这一实现细节);Secret 仍走既有 credentialSecretRequest.Password`,响应永不回显。
- Step 4: 运行测试确认通过
Run: go test ./internal/storage/ ./internal/server/ -count=1 && go vet ./internal/storage/ ./internal/server/
Expected: PASS。
- Step 5: Commit(需授权)
git add internal/storage/models.go internal/storage/credential_repository.go internal/storage/credential_repository_test.go internal/server/credential_service.go internal/server/credential_service_test.go internal/server/credential_api.go
git commit -m "feat(credentials): add proxy basic credential type"
Task 9: 内部监听与可信 peer 前置校验
Files:
- Create:
internal/server/proxy_entry_listener.go - Create:
internal/server/proxy_entry_listener_test.go - Modify:
internal/server/runtime.go(ServerRuntime增加proxyEntry *ProxyEntryListener字段、构造与ServeListener中的启动/停止) - Modify:
internal/cli/root.go(启动日志追加一行 proxy entry 状态)
Interfaces:
- Consumes: Task 1 的
config.ProxyEntryConfig、Task 10 的ProxyEntry.Handler()(本任务先用一个占位http.HandlerFunc注入,Task 10 换成真实 handler) - Produces:
type ProxyEntryListener struct{ /* unexported */ }
func NewProxyEntryListener(cfg config.ProxyEntryConfig, handler http.Handler, metrics *observability.Metrics) (*ProxyEntryListener, error)
func (l *ProxyEntryListener) Serve(ctx context.Context) error // 阻塞直到 ctx 取消或监听失败
func (l *ProxyEntryListener) Addr() net.Addr // 未开始监听时返回 nil
- Step 1: 写失败测试
package server
import (
"context"
"io"
"net"
"net/http"
"testing"
"time"
"github.com/tunnelmesh/tunnelmesh/internal/config"
)
func proxyEntryTestConfig(trusted []string) config.ProxyEntryConfig {
cfg := config.ProxyEntryConfig{
Enabled: true, Listen: "127.0.0.1:0", TrustedProxies: trusted,
RouteHeader: "X-TunnelMesh-Route", ClientIPHeader: "X-TunnelMesh-Client-IP",
ClientPortHeader: "X-TunnelMesh-Client-Port",
ConnectTimeout: time.Second, IdleTimeout: 2 * time.Second, ShutdownTimeout: time.Second,
MaxConcurrentTunnels: 2, MaxHeaderBytes: 16384, AuthBackoffThreshold: 5,
}
return cfg
}
func startProxyEntryListener(t *testing.T, cfg config.ProxyEntryConfig, handler http.Handler) *ProxyEntryListener {
t.Helper()
listener, err := NewProxyEntryListener(cfg, handler, nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- listener.Serve(ctx) }()
deadline := time.Now().Add(2 * time.Second)
for listener.Addr() == nil && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if listener.Addr() == nil {
t.Fatal("listener did not bind")
}
t.Cleanup(func() { cancel(); <-done })
return listener
}
func TestProxyEntryListenerServesTrustedPeer(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Echo-Route", r.Header.Get("X-TunnelMesh-Route"))
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
})
listener := startProxyEntryListener(t, proxyEntryTestConfig([]string{"127.0.0.1/32"}), handler)
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_, _ = conn.Write([]byte("GET http://target.example/ HTTP/1.1\r\nHost: target.example\r\nX-TunnelMesh-Route: tp-demo.tm.example.com\r\n\r\n"))
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
body, err := io.ReadAll(conn)
if err != nil {
t.Fatalf("read response: %v", err)
}
if !bytesContains(body, "200 OK") || !bytesContains(body, "X-Echo-Route: tp-demo.tm.example.com") {
t.Fatalf("response = %q", body)
}
}
func TestProxyEntryListenerClosesUntrustedPeerBeforeReading(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler must not run for an untrusted peer")
})
listener := startProxyEntryListener(t, proxyEntryTestConfig([]string{"10.9.9.9/32"}), handler)
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_, _ = conn.Write([]byte("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"))
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
body, err := io.ReadAll(conn)
if err != nil && err != io.EOF {
t.Fatalf("unexpected read error: %v", err)
}
if len(body) != 0 {
t.Fatalf("untrusted peer received %q", body)
}
}
func TestProxyEntryListenerStopsOnContextCancel(t *testing.T) {
cfg := proxyEntryTestConfig([]string{"127.0.0.1/32"})
listener, err := NewProxyEntryListener(cfg, http.NotFoundHandler(), nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- listener.Serve(ctx) }()
for listener.Addr() == nil {
time.Sleep(5 * time.Millisecond)
}
addr := listener.Addr().String()
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Serve returned %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after cancel")
}
if conn, err := net.Dial("tcp", addr); err == nil {
_ = conn.Close()
t.Fatal("listener still accepts after shutdown")
}
}
func bytesContains(haystack []byte, needle string) bool {
return len(haystack) >= len(needle) && string(haystack) != "" && contains(string(haystack), needle)
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
- Step 2: 运行测试确认失败
Run: go test ./internal/server/ -run ProxyEntryListener -count=1
Expected: FAIL,undefined: NewProxyEntryListener。
- Step 3: 最小实现
package server
// trustedPeerListener drops connections from hosts outside the configured
// allowlist before any request bytes are parsed. The internal proxy listener
// carries forged-able route and client-IP headers, so this check is the only
// thing standing between the LAN and the policy engine.
type trustedPeerListener struct {
inner net.Listener
trusted []*net.IPNet
onDeny func(remote net.Addr)
}
func (l *trustedPeerListener) Accept() (net.Conn, error) {
for {
conn, err := l.inner.Accept()
if err != nil {
return nil, err
}
if peerAllowed(conn.RemoteAddr(), l.trusted) {
return conn, nil
}
l.onDeny(conn.RemoteAddr())
_ = conn.Close()
}
}
func (l *trustedPeerListener) Addr() net.Addr { return l.inner.Addr() }
func (l *trustedPeerListener) Close() error { return l.inner.Close() }
func peerAllowed(remote net.Addr, trusted []*net.IPNet) bool {
host, _, err := net.SplitHostPort(remote.String())
if err != nil {
host = remote.String()
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, network := range trusted {
if network.Contains(ip) {
return true
}
}
return false
}
NewProxyEntryListener 解析 cfg.TrustedProxies(net.ParseCIDR,任一非法返回 error),构造 http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second, MaxHeaderBytes: cfg.MaxHeaderBytes}。Serve 按 Interfaces 描述启动、在 ctx.Done() 时先 Shutdown(ShutdownTimeout) 再 Close(),http.ErrServerClosed 归一化为 nil;onDeny 记 slog.WarnContext(ctx, "proxy_entry_untrusted_peer", "remote", addr) 并在 metrics 非 nil 时调用 ObserveProxyEntryRequest("", "connect", "untrusted_peer", "")(Task 10 提供该方法)。Addr() 用 atomic.Pointer[net.Addr] 或 mutex 保护,未监听时返回 nil。
internal/server/runtime.go:ServerRuntime 增加 proxyEntry *ProxyEntryListener 字段;构造处仅当 r.config.Server.ProxyEntry.Enabled 时 NewProxyEntryListener(cfg, entry.Handler(), r.metrics);在 ServeListener 中按 relay listener 的同一模式启动 goroutine,并把它的错误汇入既有 errCh 选择逻辑,shutdown 时一并取消。
- Step 4: 运行测试确认通过
Run: go test ./internal/server/ -run ProxyEntryListener -count=1 && go test -race ./internal/server/ -run ProxyEntryListener -count=1
Expected: PASS。
- Step 5: Commit(需授权)
git add internal/server/proxy_entry_listener.go internal/server/proxy_entry_listener_test.go internal/server/runtime.go internal/cli/root.go
git commit -m "feat(server): add trusted proxy entry listener"
Task 10: CONNECT 隧道处理、限额、指标与审计
Files:
- Create:
internal/server/proxy_entry.go - Create:
internal/server/proxy_entry_test.go - Modify:
internal/observability/metrics.go(5 个 collector + 5 个访问器 + 注册) - Create:
internal/observability/metrics_proxy_entry_test.go
Interfaces:
- Consumes:
proxyentry.RouteSource(Task 7 的ManagedRouteTable)、proxyentry.RouteIdentity(Task 3)、proxyentry.SourceACL(Task 4)、proxyentry.Authenticator(Task 5)、proxyentry.TargetPolicy(Task 6)、relay.NodeTransport.OpenStream(context.Context, relay.StreamRequest) (io.ReadWriteCloser, error)、storage.AuditRepository.Create(context.Context, storage.AuditLog) error - Produces:
type ProxyEntry struct{ /* unexported */ }
func NewProxyEntry(cfg config.ProxyEntryConfig, routes proxyentry.RouteSource, opener relay.NodeTransport, secrets proxyentry.SecretResolver, metrics *observability.Metrics, audits storage.AuditRepository) *ProxyEntry
func (p *ProxyEntry) Handler() http.Handler
func (p *ProxyEntry) ServeHTTP(w http.ResponseWriter, r *http.Request)
func (p *ProxyEntry) ActiveTunnels() int
// observability 新增访问器
func (m *Metrics) ObserveProxyEntryRequest(route, mode, result, errorClass string)
func (m *Metrics) ObserveProxyEntryTunnel(route string, active bool)
func (m *Metrics) ObserveProxyEntryTunnelDuration(route, result string, duration time.Duration)
func (m *Metrics) ObserveProxyEntryAuthFailure(route, reason string)
func (m *Metrics) ObserveProxyEntryACLDenied(route string)
- Step 1: 写指标失败测试
package observability
import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
)
func TestProxyEntryMetricsAreRegistered(t *testing.T) {
reg := prometheus.NewRegistry()
metrics := NewMetrics(reg)
metrics.ObserveProxyEntryRequest("tp-demo", "connect", "success", "")
metrics.ObserveProxyEntryTunnel("tp-demo", true)
metrics.ObserveProxyEntryTunnel("tp-demo", false)
metrics.ObserveProxyEntryTunnelDuration("tp-demo", "success", 1500*time.Millisecond)
metrics.ObserveProxyEntryAuthFailure("tp-demo", "bad_password")
metrics.ObserveProxyEntryACLDenied("tp-demo")
families, err := reg.Gather()
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
"tunnelmesh_proxy_entry_requests_total": false,
"tunnelmesh_proxy_entry_tunnels_active": false,
"tunnelmesh_proxy_entry_tunnel_duration_seconds": false,
"tunnelmesh_proxy_entry_auth_failures_total": false,
"tunnelmesh_proxy_entry_acl_denied_total": false,
}
for _, family := range families {
if _, ok := want[family.GetName()]; ok {
want[family.GetName()] = true
}
}
for name, seen := range want {
if !seen {
t.Fatalf("metric %s was not registered", name)
}
}
}
- Step 2: 运行指标测试确认失败
Run: go test ./internal/observability/ -run ProxyEntry -count=1
Expected: FAIL,metrics.ObserveProxyEntryRequest undefined。
- Step 3: 实现指标
在 internal/observability/metrics.go 中按既有 webssh 指标的同一三处写法添加:Metrics 结构字段 proxyEntryRequests *prometheus.CounterVec、proxyEntryTunnels *prometheus.GaugeVec、proxyEntryTunnelDuration *prometheus.HistogramVec、proxyEntryAuthFailures *prometheus.CounterVec、proxyEntryACLDenied *prometheus.CounterVec;NewMetrics 中用 prometheus.NewCounterVec/GaugeVec/HistogramVec 构造(label 分别为 {route,mode,result,error_class}、{route}、{route,result}、{route,reason}、{route}),并把 5 个 collector 追加到 reg.MustRegister(...) 参数列表末尾;访问器全部经既有 label(...) 归一化空值,ObserveProxyEntryTunnel(route, active) 按 active 做 Inc/Dec。
- Step 4: 运行指标测试确认通过
Run: go test ./internal/observability/ -count=1
Expected: PASS。
- Step 5: 写 CONNECT 失败测试
package server
import (
"bufio"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/tunnelmesh/tunnelmesh/internal/config"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
"github.com/tunnelmesh/tunnelmesh/internal/relay"
)
type stubProxyRoutes map[string]proxyentry.Route
func (s stubProxyRoutes) ProxyRoute(_ context.Context, domain string) (proxyentry.Route, bool) {
route, ok := s[domain]
return route, ok
}
type stubProxySecrets struct{ username, password string }
func (s stubProxySecrets) ProxyBasicSecret(context.Context, string) (proxyentry.CredentialSecret, error) {
return proxyentry.CredentialSecret{Username: s.username, Password: s.password}, nil
}
// stubEgress echoes everything the client sends, standing in for an Agent
// connection to the real target.
type stubEgress struct {
delay time.Duration
err error
requests []relay.StreamRequest
}
func (s *stubEgress) OpenStream(ctx context.Context, req relay.StreamRequest) (io.ReadWriteCloser, error) {
s.requests = append(s.requests, req)
if s.err != nil {
return nil, s.err
}
if s.delay > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(s.delay):
}
}
client, upstream := net.Pipe()
go func() {
scanner := bufio.NewReader(upstream)
for {
line, err := scanner.ReadString('\n')
if err != nil {
return
}
if _, err := io.WriteString(client, "echo:"+line); err != nil {
return
}
}
}()
return client, nil
}
func (s *stubEgress) Close() error { return nil }
// proxyEntryServer exposes the httptest listener address so tests can dial it
// and hand-write proxy requests (the Go HTTP client will not emit a CONNECT
// carrying our trusted-peer headers).
type proxyEntryServer struct{ addr string }
func (s proxyEntryServer) Addr() string { return s.addr }
// egress is typed as relay.NodeTransport so later tasks can substitute a
// scripted or blocking transport without touching the call sites.
func newProxyEntryFixture(t *testing.T, routes stubProxyRoutes, egress relay.NodeTransport, cfg config.ProxyEntryConfig) (proxyEntryServer, *ProxyEntry) {
t.Helper()
entry := NewProxyEntry(cfg, routes, egress, stubProxySecrets{username: "demo", password: "s3cret"}, nil, nil)
srv := httptest.NewServer(entry.Handler())
t.Cleanup(srv.Close)
return proxyEntryServer{addr: srv.Listener.Addr().String()}, entry
}
func dialProxyAndConnect(t *testing.T, addr, target, route, clientIP, proxyAuth string) (net.Conn, *bufio.ReadWriter) {
t.Helper()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
var b strings.Builder
fmt.Fprintf(&b, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", target, target)
fmt.Fprintf(&b, "X-TunnelMesh-Route: %s\r\nX-TunnelMesh-Client-IP: %s\r\nX-TunnelMesh-Client-Port: 41234\r\n", route, clientIP)
if proxyAuth != "" {
fmt.Fprintf(&b, "Proxy-Authorization: Basic %s\r\n", base64.StdEncoding.EncodeToString([]byte(proxyAuth)))
}
b.WriteString("\r\n")
if _, err := io.WriteString(conn, b.String()); err != nil {
t.Fatal(err)
}
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
return conn, bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
}
func readStatusLine(t *testing.T, rw *bufio.ReadWriter) (int, http.Header) {
t.Helper()
resp, err := http.ReadResponse(rw.Reader, nil)
if err != nil {
t.Fatalf("read proxy response: %v", err)
}
return resp.StatusCode, resp.Header
}
func TestProxyEntryConnectTunnelRoundTrip(t *testing.T) {
routes := stubProxyRoutes{"tp-demo.tm.example.com": {
ID: "r1", Domain: "tp-demo.tm.example.com", Name: "demo", AgentID: "agent-1",
AuthMode: proxyentry.AuthModeNone, SourceCIDRs: []string{"11.71.85.0/24"},
AllowPrivateTargets: true, Status: proxyentry.StatusActive,
}}
egress := &stubEgress{}
srv, entry := newProxyEntryFixture(t, routes, egress, proxyEntryTestConfig([]string{"127.0.0.1/32"}))
conn, rw := dialProxyAndConnect(t, srv.Addr(), "93.184.216.34:443", "tp-demo.tm.example.com", "11.71.85.7", "")
status, _ := readStatusLine(t, rw)
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if _, err := conn.Write([]byte("ping\n")); err != nil {
t.Fatal(err)
}
line, err := rw.ReadString('\n')
if err != nil {
t.Fatalf("read echo: %v", err)
}
if line != "echo:ping\n" {
t.Fatalf("echo = %q", line)
}
if len(egress.requests) != 1 {
t.Fatalf("egress requests = %#v", egress.requests)
}
req := egress.requests[0]
if req.AgentID != "agent-1" || req.Protocol != "tcp" || req.TargetHost != "93.184.216.34" || req.TargetPort != 443 {
t.Fatalf("stream request = %#v", req)
}
_ = entry
}
func TestProxyEntryDenials(t *testing.T) {
basic := proxyentry.Route{
ID: "r1", Domain: "tp-demo.tm.example.com", Name: "demo", AgentID: "agent-1",
AuthMode: proxyentry.AuthModeBasic, CredentialID: "c1",
SourceCIDRs: []string{"11.71.85.0/24"}, AllowPrivateTargets: true, Status: proxyentry.StatusActive,
}
routes := stubProxyRoutes{"tp-demo.tm.example.com": basic}
cases := []struct {
name string
route string
clientIP string
auth string
target string
wantCode int
wantError string
}{
{"unknown route", "tp-nope.tm.example.com", "11.71.85.7", "", "93.184.216.34:443", http.StatusForbidden, "proxy_route_unavailable"},
{"missing client ip", "tp-demo.tm.example.com", "", "", "93.184.216.34:443", http.StatusForbidden, "proxy_route_identity_invalid"},
{"acl deny", "tp-demo.tm.example.com", "10.0.0.9", "", "93.184.216.34:443", http.StatusForbidden, "proxy_source_denied"},
{"auth missing", "tp-demo.tm.example.com", "11.71.85.7", "", "93.184.216.34:443", http.StatusProxyAuthRequired, "proxy_auth_required"},
{"auth wrong", "tp-demo.tm.example.com", "11.71.85.7", "demo:bad", "93.184.216.34:443", http.StatusProxyAuthRequired, "proxy_auth_failed"},
{"metadata target", "tp-demo.tm.example.com", "11.71.85.7", "demo:s3cret", "169.254.169.254:80", http.StatusForbidden, "proxy_target_denied"},
{"bad port", "tp-demo.tm.example.com", "11.71.85.7", "demo:s3cret", "93.184.216.34:0", http.StatusBadRequest, "proxy_target_invalid"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv, _ := newProxyEntryFixture(t, routes, &stubEgress{}, proxyEntryTestConfig([]string{"127.0.0.1/32"}))
_, rw := dialProxyAndConnect(t, srv.Addr(), tc.target, tc.route, tc.clientIP, tc.auth)
status, header := readStatusLine(t, rw)
if status != tc.wantCode {
t.Fatalf("status = %d want %d", status, tc.wantCode)
}
if status == http.StatusProxyAuthRequired && header.Get("Proxy-Authenticate") == "" {
t.Fatal("407 must carry Proxy-Authenticate")
}
body, _ := io.ReadAll(rw.Reader)
if !strings.Contains(string(body), tc.wantError) {
t.Fatalf("body = %q want code %q", body, tc.wantError)
}
})
}
}
func TestProxyEntryEgressErrors(t *testing.T) {
routes := stubProxyRoutes{"tp-demo.tm.example.com": {
ID: "r1", Domain: "tp-demo.tm.example.com", Name: "demo", AgentID: "agent-1",
AuthMode: proxyentry.AuthModeNone, AllowPrivateTargets: true, Status: proxyentry.StatusActive,
}}
cfg := proxyEntryTestConfig([]string{"127.0.0.1/32"})
failing := &stubEgress{err: fmt.Errorf("agent offline")}
srv, _ := newProxyEntryFixture(t, routes, failing, cfg)
_, rw := dialProxyAndConnect(t, srv.Addr(), "93.184.216.34:443", "tp-demo.tm.example.com", "10.0.0.1", "")
status, _ := readStatusLine(t, rw)
body, _ := io.ReadAll(rw.Reader)
if status != http.StatusBadGateway || !strings.Contains(string(body), "proxy_egress_unavailable") {
t.Fatalf("egress failure = %d %q", status, body)
}
cfg.ConnectTimeout = 50 * time.Millisecond
slow := &stubEgress{delay: time.Second}
slowSrv, _ := newProxyEntryFixture(t, routes, slow, cfg)
_, slowRW := dialProxyAndConnect(t, slowSrv.Addr(), "93.184.216.34:443", "tp-demo.tm.example.com", "10.0.0.1", "")
status, _ = readStatusLine(t, slowRW)
body, _ = io.ReadAll(slowRW.Reader)
if status != http.StatusGatewayTimeout || !strings.Contains(string(body), "proxy_egress_timeout") {
t.Fatalf("timeout = %d %q", status, body)
}
}
// blockingEgress keeps every opened stream alive until the test closes it, so
// the per-route concurrency limit can be exercised deterministically.
type blockingEgress struct {
opened chan struct{}
mu sync.Mutex
conns []io.ReadWriteCloser
}
func (b *blockingEgress) OpenStream(context.Context, relay.StreamRequest) (io.ReadWriteCloser, error) {
client, upstream := net.Pipe()
b.mu.Lock()
b.conns = append(b.conns, client, upstream)
b.mu.Unlock()
select {
case b.opened <- struct{}{}:
default:
}
return client, nil
}
func (b *blockingEgress) Close() error { return nil }
func TestProxyEntryCapacityLimit(t *testing.T) {
routes := stubProxyRoutes{"tp-demo.tm.example.com": {
ID: "r1", Domain: "tp-demo.tm.example.com", Name: "demo", AgentID: "agent-1",
AuthMode: proxyentry.AuthModeNone, AllowPrivateTargets: true,
MaxConcurrentTunnels: 1, Status: proxyentry.StatusActive,
}}
blocking := &blockingEgress{opened: make(chan struct{}, 4)}
srv, entry := newProxyEntryFixture(t, routes, blocking, proxyEntryTestConfig([]string{"127.0.0.1/32"}))
first, firstRW := dialProxyAndConnect(t, srv.Addr(), "93.184.216.34:443", "tp-demo.tm.example.com", "10.0.0.1", "")
if status, _ := readStatusLine(t, firstRW); status != http.StatusOK {
t.Fatalf("first tunnel status = %d", status)
}
<-blocking.opened
if got := entry.ActiveTunnels(); got != 1 {
t.Fatalf("ActiveTunnels while connected = %d", got)
}
_, secondRW := dialProxyAndConnect(t, srv.Addr(), "93.184.216.34:443", "tp-demo.tm.example.com", "10.0.0.2", "")
status, header := readStatusLine(t, secondRW)
if status != http.StatusServiceUnavailable || header.Get("Retry-After") != "5" {
t.Fatalf("second tunnel = %d Retry-After=%q", status, header.Get("Retry-After"))
}
body, _ := io.ReadAll(secondRW.Reader)
if !strings.Contains(string(body), "proxy_capacity_exhausted") {
t.Fatalf("body = %q", body)
}
// Ending the first tunnel must release its slot again.
_ = first.Close()
deadline := time.Now().Add(3 * time.Second)
for entry.ActiveTunnels() != 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if got := entry.ActiveTunnels(); got != 0 {
t.Fatalf("slot not released, ActiveTunnels = %d", got)
}
}
func TestClassifyTunnelResult(t *testing.T) {
dnsTimeout := &net.DNSError{IsTimeout: true}
cases := []struct {
name string
idleFired bool
errs []error
want string
}{
{"both directions clean", false, []error{nil, io.EOF}, "success"},
{"shutdown artifacts are clean", false, []error{net.ErrClosed, os.ErrDeadlineExceeded}, "success"},
{"closed pipe is clean", false, []error{io.ErrClosedPipe, nil}, "success"},
{"deadline error is clean", false, []error{dnsTimeout, nil}, "success"},
{"transport fault is an error", false, []error{nil, errors.New("relay reset by peer")}, "error"},
{"idle watchdog wins", true, []error{errors.New("relay reset by peer")}, "timeout"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := classifyTunnelResult(tc.idleFired, tc.errs...); got != tc.want {
t.Fatalf("classifyTunnelResult(%v, %v) = %q, want %q", tc.idleFired, tc.errs, got, tc.want)
}
})
}
}
- Step 6: 运行 CONNECT 测试确认失败
Run: go test ./internal/server/ -run 'TestProxyEntry' -count=1
Expected: FAIL,undefined: NewProxyEntry。
- Step 7: 最小实现
本任务只交付 CONNECT 分支。handleAbsoluteForm 先以编译占位存在,Task 11 在同一 PR 内立即替换为真实实现并补测试:
// handleAbsoluteForm is a compile-time placeholder in Task 10; Task 11 replaces
// the body. The message never reaches production because both tasks land in the
// same release.
func (p *ProxyEntry) handleAbsoluteForm(w http.ResponseWriter, r *http.Request, route proxyentry.Route, clientIP net.IP, mode string, started time.Time) {
p.deny(w, r, route.Domain, mode, started, proxyentry.NewError(http.StatusNotImplemented, "proxy_absolute_form_unimplemented", "absolute-form forwarding is not implemented yet"), "absolute_unimplemented")
}
internal/server/proxy_entry.go 的 ServeHTTP 决策链(顺序不可调整,任何一步失败都必须先记指标再写响应):
func (p *ProxyEntry) ServeHTTP(w http.ResponseWriter, r *http.Request) {
started := time.Now()
mode := "absolute"
if r.Method == http.MethodConnect {
mode = "connect"
}
clientIP, ipErr := proxyentry.ParseClientIP(r.Header.Get(p.config.ClientIPHeader))
key, routeErr := p.identity.Resolve(r)
if ipErr != nil || routeErr != nil {
p.deny(w, r, "", mode, started, proxyentry.ErrRouteIdentityInvalid, "identity")
return
}
routeName := string(key)
route, ok := p.routes.ProxyRoute(r.Context(), routeName)
if !ok || !route.Active() {
p.deny(w, r, routeName, mode, started, proxyentry.ErrRouteUnavailable, "route_unavailable")
return
}
acl, err := proxyentry.NewSourceACL(route.SourceCIDRs)
if err != nil {
acl = proxyentry.DenyAllSourceACL()
}
if !acl.Allow(clientIP) {
p.metricsACLDenied(route)
p.audit(r.Context(), "proxy_route_denied", route, clientIP, "", 0, "source_acl")
p.deny(w, r, routeName, mode, started, proxyentry.ErrSourceDenied, "source_acl")
return
}
if err := p.authenticator.Authorize(r.Context(), route, clientIP, r.Header.Get("Proxy-Authorization")); err != nil {
p.metricsAuthFailure(route, err)
p.audit(r.Context(), "proxy_auth_failed", route, clientIP, "", 0, errorCode(err))
p.deny(w, r, routeName, mode, started, err, "auth")
return
}
release, err := p.reserve(route)
if err != nil {
p.deny(w, r, routeName, mode, started, err, "capacity")
return
}
defer release()
if r.Method == http.MethodConnect {
p.handleConnect(w, r, route, clientIP, mode, started)
return
}
p.handleAbsoluteForm(w, r, route, clientIP, mode, started)
}
deny 写 {"code":<status>,"msg":"<固定文案>","data":null}(复用既有 writeAPIError 的信封格式,但错误码取自 proxyentry.Error.Code),设置 Error.HTTPHeaders() 中的每个头,Cache-Control: no-store,并调用 ObserveProxyEntryRequest(route, mode, "denied", errorClass)。
handleConnect:
host, port, err := net.SplitHostPort(r.Host);err时回退host = r.Host; port = 443;port非法 →ErrTargetInvalid。policy, err := proxyentry.NewTargetPolicy(route.TargetCIDRs, route.TargetPorts, route.AllowPrivateTargets);err→ErrTargetDenied;policy.Validate(host, port)失败按错误类型回 400/403。ctx, cancel := context.WithTimeout(r.Context(), p.config.ConnectTimeout);stream, err := p.opener.OpenStream(ctx, relay.StreamRequest{AgentID: route.AgentID, Protocol: "tcp", TargetHost: host, TargetPort: port, Metadata: []byte(traceparent)});errors.Is(err, context.DeadlineExceeded)→ErrEgressTimeout,其它 →ErrEgressUnavailable。hj, ok := w.(http.Hijacker);不 ok 时stream.Close()并回 500。conn, buf, err := hj.Hijack();写HTTP/1.1 200 Connection Established\r\n\r\n;若buf.Reader.Buffered() > 0先io.CopyN(stream, buf.Reader, int64(buffered))(与internal/server/http_proxy.go既有做法一致)。p.audit(ctx, "proxy_tunnel_opened", route, clientIP, host+":"+port, 0, ""),ObserveProxyEntryTunnel(route.Domain, true),ActiveTunnels计数 +1。fromClient, fromUpstream, reason := spliceWithIdleTimeout(conn, stream, p.config.IdleTimeout);结束后ObserveProxyEntryTunnel(route.Domain, false)、ObserveProxyEntryTunnelDuration(route.Domain, reason, elapsed)、ObserveBytes("proxy_entry", "upstream"/"downstream", "tcp", n)、p.audit(ctx, "proxy_tunnel_closed", ...)(Details 的reason用同一个值,另含 bytesUp/bytesDown/durationMs)。reason只有三个取值:success(两个方向都干净结束)、timeout(idle 看门狗触发)、error(任一方向的 copy 返回非干净关闭的错误),由classifyTunnelResult计算;Task 15 的 Grafana 面板按result分组出图,因此这三个取值就是该标签的完整枚举。
// spliceWithIdleTimeout copies both directions and closes the pair once neither
// side has produced bytes for the whole timeout. The mesh stream ignores
// SetDeadline, so the watchdog owns idle detection.
//
// reason is one of "success", "timeout" and "error". It labels
// tunnelmesh_proxy_entry_tunnel_duration_seconds and the proxy_tunnel_closed
// audit event, so an agent dropping mid-tunnel stays visible instead of being
// averaged into one success bucket.
func spliceWithIdleTimeout(down net.Conn, up io.ReadWriteCloser, idle time.Duration) (fromClient, fromUpstream int64, reason string) {
var last atomic.Int64
last.Store(time.Now().UnixNano())
var idleFired atomic.Bool
done := make(chan struct{})
var once sync.Once
closeAll := func() {
once.Do(func() {
_ = down.SetDeadline(time.Now())
_ = up.Close()
close(done)
})
}
// 字节数经带缓冲的 channel 交回主 goroutine,不写命名返回值:closeAll 只保证
// 有一侧先结束,另一侧此刻可能仍在 io.Copy 里,主 goroutine 直接读命名返回值
// 会被 go test -race 判成数据竞争。容量为 2,两个方向的发送都不会阻塞。
type copyResult struct {
toUpstream bool
n int64
err error
}
results := make(chan copyResult, 2)
go func() {
n, err := io.Copy(up, &activityReader{r: down, last: &last})
results <- copyResult{toUpstream: true, n: n, err: err}
closeAll()
}()
go func() {
n, err := io.Copy(down, &activityReader{r: up, last: &last})
results <- copyResult{toUpstream: false, n: n, err: err}
closeAll()
}()
go func() {
ticker := time.NewTicker(min(idle/4, 15*time.Second))
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if time.Since(time.Unix(0, last.Load())) >= idle {
idleFired.Store(true)
closeAll()
return
}
}
}
}()
// 两个方向都收完再返回。closeAll 已经让 down 的 deadline 过期、up 关闭,
// 两次 copy 都会很快返回,因此这里不会把调用方挂住。
errs := make([]error, 0, 2)
for i := 0; i < 2; i++ {
res := <-results
if res.toUpstream {
fromClient = res.n
} else {
fromUpstream = res.n
}
errs = append(errs, res.err)
}
return fromClient, fromUpstream, classifyTunnelResult(idleFired.Load(), errs...)
}
// classifyTunnelResult maps the idle watchdog flag and the two copy errors onto
// the low-cardinality result label. Anything that is the expected consequence of
// closeAll counts as a clean shutdown; only a real transport fault is an error.
// The watchdog wins because it already explains both copy failures.
func classifyTunnelResult(idleFired bool, errs ...error) string {
if idleFired {
return "timeout"
}
for _, err := range errs {
switch {
case err == nil,
errors.Is(err, io.EOF),
errors.Is(err, io.ErrClosedPipe),
errors.Is(err, net.ErrClosed),
errors.Is(err, os.ErrDeadlineExceeded):
default:
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
return "error"
}
}
return "success"
}
activityReader 在每次成功 Read 后 last.Store(time.Now().UnixNano());reserve(route) 用互斥锁维护 activeTotal 与 activeByRoute[route.ID],超过 config.MaxConcurrentTunnels(0 表示不限)或 route.MaxConcurrentTunnels 时返回 ErrCapacityExhausted,返回的 release 幂等。audit 通过 p.audits.Create(ctx, storage.AuditLog{Action: action, ResourceType: "proxy_route", ResourceID: route.ID, Details: <JSON>}) 写入,Details 含 routeDomain、agentId、clientIp、target、bytesUp、bytesDown、durationMs、reason;写失败只记 slog.WarnContext,不得影响隧道。
internal/server/proxy_entry.go 的 import 需要 errors、os、sync/atomic(classifyTunnelResult 用前两个,spliceWithIdleTimeout 用第三个);internal/server/proxy_entry_test.go 的 import 已在 Step 5 一并给出。管理侧的 proxy_route_created|updated|deleted 审计不新增事件名:既有 auditRoute 已经为所有 tunnels 行写 route.created / route.updated / route.deleted,Task 12 直接复用,spec 第 13 节的三个名字按此对齐(Task 15 Step 15 回写)。
- Step 8: 运行测试确认通过
Run: go test ./internal/server/ ./internal/observability/ -count=1 && go test -race ./internal/server/ -run 'TestProxyEntry|TestClassifyTunnelResult' -count=1 && go vet ./internal/server/ ./internal/observability/
Expected: PASS。
- Step 9: Commit(需授权)
git add internal/server/proxy_entry.go internal/server/proxy_entry_test.go internal/observability/metrics.go internal/observability/metrics_proxy_entry_test.go
git commit -m "feat(server): tunnel connect proxy entries"
Task 11: 绝对形式(非 CONNECT)HTTP 转发
Files:
- Modify:
internal/server/proxy_entry.go(替换 Task 10 的handleAbsoluteForm占位实现) - Create:
internal/server/proxy_entry_absolute_test.go
Interfaces:
- Consumes:
internal/server/http_proxy.go的copyResponse(w http.ResponseWriter, resp *http.Response)(已核实签名)、Task 10 的ProxyEntry/deny/reserve/metricsRequest/audit、proxyentry.NewTargetPolicy、relay.NodeTransport.OpenStream - Produces:
// handleAbsoluteForm forwards a non-CONNECT proxy request over one logical
// stream. Field-for-field it mirrors HTTPProxyHandler.ServeRoute: write the
// normalized request into the stream, read one response back, stream it out.
func (p *ProxyEntry) handleAbsoluteForm(w http.ResponseWriter, r *http.Request, route proxyentry.Route, clientIP net.IP, mode string, started time.Time)
// normalizeProxyEntryRequest rewrites an absolute-form proxy request into the
// origin-form the target expects and strips hop-by-hop plus trusted-peer
// headers. Mirrors internal/client.normalizeProxyRequest and additionally
// removes the X-TunnelMesh-* headers so route metadata never reaches a target.
func normalizeProxyEntryRequest(r *http.Request, host string, port int) *http.Request
// splitProxyTarget resolves the real target of a non-CONNECT proxy request.
// It accepts both request shapes the entry can see: an absolute-form URL
// (direct hit on the internal listener, used by tests and curl) and the
// origin-form rewrite OpenResty's "location /" produces, where the authority
// survives only in the Host header.
func splitProxyTarget(r *http.Request) (host string, port int, scheme string, err error)
- Step 1: 写失败测试
package server
import (
"bufio"
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/tunnelmesh/tunnelmesh/internal/proxyentry"
"github.com/tunnelmesh/tunnelmesh/internal/relay"
)
// scriptedEgress records the exact request bytes the entry forwarded and
// answers with a canned response, so origin-form rewriting and header stripping
// can be asserted without dialing a real target.
type scriptedEgress struct {
response string
err error
mu sync.Mutex
got string
requests []relay.StreamRequest
}
func (s *scriptedEgress) OpenStream(_ context.Context, req relay.StreamRequest) (io.ReadWriteCloser, error) {
s.mu.Lock()
s.requests = append(s.requests, req)
err := s.err
s.mu.Unlock()
if err != nil {
return nil, err
}
client, upstream := net.Pipe()
go func() {
defer func() { _ = upstream.Close() }()
reader := bufio.NewReader(upstream)
var b strings.Builder
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
return
}
b.WriteString(line)
if line == "\r\n" {
break
}
}
s.mu.Lock()
s.got = b.String()
s.mu.Unlock()
_, _ = io.WriteString(upstream, s.response)
}()
return client, nil
}
func (s *scriptedEgress) Close() error { return nil }
func (s *scriptedEgress) forwarded() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.got
}
func (s *scriptedEgress) streamRequest() relay.StreamRequest {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.requests) == 0 {
return relay.StreamRequest{}
}
return s.requests[0]
}
// dialProxyAbsolute hand-writes an absolute-form proxy request. net/http cannot
// be used because it refuses to emit the trusted-peer headers on a plain
// request and would normalize the absolute-form URL away.
func dialProxyAbsolute(t *testing.T, addr, target, route, clientIP string, extra map[string]string) *http.Response {
t.Helper()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
var b strings.Builder
fmt.Fprintf(&b, "GET %s HTTP/1.1\r\nHost: %s\r\n", target, strings.TrimPrefix(strings.TrimPrefix(target, "http://"), "https://"))
fmt.Fprintf(&b, "X-TunnelMesh-Route: %s\r\nX-TunnelMesh-Client-IP: %s\r\nX-TunnelMesh-Client-Port: 41234\r\n", route, clientIP)
for key, value := range extra {
fmt.Fprintf(&b, "%s: %s\r\n", key, value)
}
b.WriteString("\r\n")
if _, err := io.WriteString(conn, b.String()); err != nil {
t.Fatal(err)
}
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read absolute-form response: %v", err)
}
return resp
}
func proxyAbsoluteRoutes() stubProxyRoutes {
return stubProxyRoutes{"tp-demo.tm.example.com": {
ID: "r1", Domain: "tp-demo.tm.example.com", Name: "demo", AgentID: "agent-1",
AuthMode: proxyentry.AuthModeNone, AllowPrivateTargets: true,
Status: proxyentry.StatusActive,
}}
}
func TestProxyEntryAbsoluteFormRewritesToOriginForm(t *testing.T) {
egress := &scriptedEgress{response: "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nX-Target: real\r\n\r\nhello"}
srv, _ := newProxyEntryFixture(t, proxyAbsoluteRoutes(), egress, proxyEntryTestConfig([]string{"127.0.0.1/32"}))
resp := dialProxyAbsolute(t, srv.Addr(), "http://93.184.216.34:8080/status", "tp-demo.tm.example.com", "11.71.85.7", map[string]string{
"Proxy-Authorization": "Basic ZGVtbzpzM2NyZXQ=",
"Proxy-Connection": "keep-alive",
"X-TunnelMesh-Extra": "must-not-leak",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if got := resp.Header.Get("X-Target"); got != "real" {
t.Fatalf("upstream header = %q", got)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "hello" {
t.Fatalf("body = %q", body)
}
forwarded := egress.forwarded()
if !strings.HasPrefix(forwarded, "GET /status HTTP/1.1\r\n") {
t.Fatalf("request line not rewritten to origin-form: %q", forwarded)
}
if !strings.Contains(forwarded, "\r\nHost: 93.184.216.34:8080\r\n") {
t.Fatalf("Host header = %q", forwarded)
}
for _, banned := range []string{"Proxy-Authorization", "Proxy-Connection", "X-TunnelMesh-Route", "X-TunnelMesh-Client-IP", "X-TunnelMesh-Client-Port", "X-TunnelMesh-Extra"} {
if strings.Contains(forwarded, banned) {
t.Fatalf("%s leaked upstream: %q", banned, forwarded)
}
}
req := egress.streamRequest()
if req.AgentID != "agent-1" || req.Protocol != "http" || req.TargetHost != "93.184.216.34" || req.TargetPort != 8080 || req.TargetScheme != "http" {
t.Fatalf("stream request = %#v", req)
}
}
func TestProxyEntryAbsoluteFormDefaultsPortByScheme(t *testing.T) {
cases := []struct {
raw string
hostHeader string
wantHost string
wantPort int
wantScheme string
}{
{"http://intranet.example.com/", "intranet.example.com", "intranet.example.com", 80, "http"},
{"https://intranet.example.com/", "intranet.example.com", "intranet.example.com", 443, "https"},
{"https://[fd00::1]:8443/api", "[fd00::1]:8443", "fd00::1", 8443, "https"},
// OpenResty's location / rewrites the request line to origin-form and
// forwards the authority in Host; HTTPS targets never take this path
// because they always arrive as CONNECT.
{"/status", "93.184.216.34:8080", "93.184.216.34", 8080, "http"},
{"/", "intranet.example.com", "intranet.example.com", 80, "http"},
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, tc.raw, nil)
req.Host = tc.hostHeader
host, port, scheme, err := splitProxyTarget(req)
if err != nil {
t.Fatalf("%s (Host %s): %v", tc.raw, tc.hostHeader, err)
}
if host != tc.wantHost || port != tc.wantPort || scheme != tc.wantScheme {
t.Fatalf("%s (Host %s) -> %s:%d/%s", tc.raw, tc.hostHeader, host, port, scheme)
}
}
if _, _, _, err := splitProxyTarget(httptest.NewRequest(http.MethodGet, "/relative", nil)); err == nil {
t.Fatal("origin-form without a Host authority must be rejected")
}
}
func TestProxyEntryAbsoluteFormDeniesAndReportsEgress(t *testing.T) {
denied := &scriptedEgress{response: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"}
srv, _ := newProxyEntryFixture(t, proxyAbsoluteRoutes(), denied, proxyEntryTestConfig([]string{"127.0.0.1/32"}))
resp := dialProxyAbsolute(t, srv.Addr(), "http://169.254.169.254/latest/meta-data/", "tp-demo.tm.example.com", "11.71.85.7", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("metadata target status = %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "proxy_target_denied") {
t.Fatalf("body = %q", body)
}
failing := &scriptedEgress{err: fmt.Errorf("agent offline")}
failSrv, _ := newProxyEntryFixture(t, proxyAbsoluteRoutes(), failing, proxyEntryTestConfig([]string{"127.0.0.1/32"}))
failResp := dialProxyAbsolute(t, failSrv.Addr(), "http://93.184.216.34:8080/status", "tp-demo.tm.example.com", "11.71.85.7", nil)
defer failResp.Body.Close()
if failResp.StatusCode != http.StatusBadGateway {
t.Fatalf("egress failure status = %d", failResp.StatusCode)
}
failBody, _ := io.ReadAll(failResp.Body)
if !strings.Contains(string(failBody), "proxy_egress_unavailable") {
t.Fatalf("body = %q", failBody)
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/server/ -run 'TestProxyEntryAbsoluteForm' -count=1
Expected: FAIL。前两条因为 undefined: splitProxyTarget 直接编译失败;即使先补上 splitProxyTarget,TestProxyEntryAbsoluteFormRewritesToOriginForm 也会因为 Task 10 占位返回 501 而失败(status = 501)。
- Step 3: 最小实现
替换 internal/server/proxy_entry.go 中 Task 10 的占位 handleAbsoluteForm:
func (p *ProxyEntry) handleAbsoluteForm(w http.ResponseWriter, r *http.Request, route proxyentry.Route, clientIP net.IP, mode string, started time.Time) {
host, port, scheme, err := splitProxyTarget(r)
if err != nil {
p.deny(w, r, route.Domain, mode, started, proxyentry.ErrTargetInvalid, "target")
return
}
policy, err := proxyentry.NewTargetPolicy(route.TargetCIDRs, route.TargetPorts, route.AllowPrivateTargets)
if err != nil {
p.deny(w, r, route.Domain, mode, started, proxyentry.ErrTargetDenied, "target_policy")
return
}
if err := policy.Validate(host, port); err != nil {
p.deny(w, r, route.Domain, mode, started, targetError(err), "target")
return
}
ctx, cancel := context.WithTimeout(r.Context(), p.config.ConnectTimeout)
defer cancel()
stream, err := p.opener.OpenStream(ctx, relay.StreamRequest{
AgentID: route.AgentID, Protocol: "http",
TargetHost: host, TargetPort: port, TargetScheme: scheme, HostHeader: host,
Metadata: []byte(traceparentFromContext(ctx)),
})
if err != nil {
p.deny(w, r, route.Domain, mode, started, egressError(err), "egress")
return
}
defer stream.Close()
upstreamReq := normalizeProxyEntryRequest(r, host, port)
if err := upstreamReq.Write(stream); err != nil {
p.deny(w, r, route.Domain, mode, started, proxyentry.ErrEgressUnavailable, "egress_write")
return
}
resp, err := http.ReadResponse(bufio.NewReader(stream), upstreamReq)
if err != nil {
p.deny(w, r, route.Domain, mode, started, proxyentry.ErrEgressUnavailable, "egress_read")
return
}
defer resp.Body.Close()
p.audit(ctx, "proxy_request_forwarded", route, clientIP, net.JoinHostPort(host, strconv.Itoa(port)), 0, "")
p.metrics.ObserveProxyEntryRequest(route.Domain, mode, "success", "")
copyResponse(w, resp)
}
func splitProxyTarget(r *http.Request) (string, int, string, error) {
if r == nil {
return "", 0, "", proxyentry.ErrTargetInvalid
}
target, scheme := r.URL, r.URL.Scheme
if target.Host == "" {
// Origin-form: the authority survives only in Host. Defaulting the
// scheme to http is correct, not a guess - an https:// target always
// reaches this entry as CONNECT and is handled by handleConnect.
authority := strings.TrimSpace(r.Host)
if authority == "" {
return "", 0, "", proxyentry.ErrTargetInvalid
}
parsed, err := url.Parse("http://" + authority)
if err != nil {
return "", 0, "", proxyentry.ErrTargetInvalid
}
target, scheme = parsed, "http"
}
if scheme != "http" && scheme != "https" {
return "", 0, "", proxyentry.ErrTargetInvalid
}
host := target.Hostname()
portText := target.Port()
if portText == "" {
if scheme == "https" {
portText = "443"
} else {
portText = "80"
}
}
port, err := strconv.Atoi(portText)
if err != nil || port < 1 || port > 65535 || host == "" {
return "", 0, "", proxyentry.ErrTargetInvalid
}
return host, port, scheme, nil
}
func normalizeProxyEntryRequest(r *http.Request, host string, port int) *http.Request {
upstream := r.Clone(r.Context())
upstream.RequestURI = ""
upstream.URL.Scheme = ""
upstream.URL.Host = ""
upstream.Host = net.JoinHostPort(host, strconv.Itoa(port))
// Hop-by-hop headers are meaningful only between the client and this entry.
upstream.Header.Del("Proxy-Authorization")
upstream.Header.Del("Proxy-Connection")
upstream.Header.Del("Connection")
upstream.Header.Del("Keep-Alive")
upstream.Header.Del("Te")
upstream.Header.Del("Trailer")
upstream.Header.Del("Transfer-Encoding")
upstream.Header.Del("Upgrade")
// Route metadata is injected by OpenResty and must never reach the target.
// Map keys are already canonical, so one prefix sweep covers every
// X-TunnelMesh-* header without enumerating them.
for key := range upstream.Header {
if strings.HasPrefix(key, "X-Tunnelmesh-") {
upstream.Header.Del(key)
}
}
return upstream
}
实现约束:
targetError(err)与egressError(err)是 Task 10 已定义的映射函数:targetError把proxyentry.ErrTargetInvalid原样返回、其它一律ErrTargetDenied;egressError把context.DeadlineExceeded映射为ErrEgressTimeout、其它映射为ErrEgressUnavailable。若 Task 10 中它们仍是内联判断,本任务先把它们抽成这两个函数再复用,避免 CONNECT 与绝对形式两条分支的映射漂移。Connection/Upgrade等头必须删除:绝对形式分支不支持协议升级(WebSocket 走托管路由反代,不走代理入口),留着会让http.ReadResponse期待 101。- 删除
Transfer-Encoding后upstreamReq.Write会按 body 实际长度写Content-Length;请求体转发依赖r.Body,因此 listener 必须保持proxy_request_buffering off(nginx 侧已在 Task 14 配置)。 - 不设置
http.Client:与ServeRoute一致,直接写 stream 再读一个响应,避免额外的连接池与重定向语义。重定向原样透传给客户端(copyResponse复制 3xx 与Location),由浏览器自己决定是否再次经过代理。 traceparentFromContext供两条分支共用:Task 10 的handleConnect已在Metadata里写 traceparent,本任务把那段内联逻辑抽成traceparentFromContext(ctx) string(返回空串表示无 trace 上下文),CONNECT 分支同步改为调用它,避免两份实现漂移。-
p.metrics.ObserveProxyEntryRequest是 Task 10 已注册的访问器;若 Task 10 已把它包成私有 helper(例如p.metricsRequest),本任务复用该 helper 而不是直接访问字段。 - Step 4: 运行测试确认通过
Run: go test ./internal/server/ -run 'TestProxyEntry' -count=1 && go test -race ./internal/server/ -run 'TestProxyEntry' -count=1 && go vet ./internal/server/
Expected: PASS(Task 10 的 CONNECT 测试同时不回归)。
- Step 5: Commit(需授权)
git add internal/server/proxy_entry.go internal/server/proxy_entry_absolute_test.go
git commit -m "feat(server): forward absolute-form proxy requests"
Task 12: 管理 API(/api/v1/routes 与 /api/v1/credentials 扩展)
Files:
- Modify:
internal/server/api.go(tunnelRequest、tunnelUpdateRequest、createTunnel、updateTunnel、apiService.CreateTunnel、publicTunnel) - Modify:
internal/server/proxy_entry_routes.go(把 Task 7 内联的匿名 config 结构提为具名proxyRouteConfig,新增normalizeProxyRouteConfig、proxyRouteConfigOf) - Modify:
internal/server/credential_api.go(credentialRequest.Username、credentialResponse.Username、create/patch 透传) - Create:
internal/server/api_proxy_route_test.go - Modify:
internal/server/credential_api_test.go(追加proxy_basic用例) - Modify:
docs/api/openapi.yaml
Interfaces:
- Consumes:
storage.ProtocolHTTPProxy/storage.ProxyTargetWildcard(Task 7)、storage.CredentialTypeProxyBasic(Task 8)、proxyentry.NormalizeCIDR/proxyentry.NewSourceACL(Task 4)、proxyentry.NewTargetPolicy(Task 6)、routing.ValidateDomainPattern、a.credentialService.Get(ctx, auth.Principal, id)、既有测试夹具apiTestServer/apiToken/apiJSON/setCredentialSecretStore - Produces:
// proxyRouteConfig is the single authority for the tp-* config JSON shape:
// loadProxyRoutes decodes it and normalizeProxyRouteConfig encodes it.
type proxyRouteConfig struct {
AuthMode string `json:"authMode"`
CredentialID string `json:"credentialId,omitempty"`
SourceCIDRs []string `json:"sourceCIDRs"`
TargetCIDRs []string `json:"targetCIDRs"`
TargetPorts []int `json:"targetPorts"`
AllowPrivateTargets *bool `json:"allowPrivateTargets"`
MaxConcurrentTunnels int `json:"maxConcurrentTunnels,omitempty"`
Description string `json:"description,omitempty"`
}
// normalizeProxyRouteConfig validates one proxy route policy and returns the
// canonical JSON stored in tunnels.config. The second return value is the
// caller-facing 400 message, empty when the policy is valid.
func normalizeProxyRouteConfig(in proxyRouteConfig) (string, string)
// proxyRouteConfigOf decodes a stored tunnels.config back into the typed shape,
// applying the documented defaults (authMode=none, allowPrivateTargets=true).
func proxyRouteConfigOf(raw string) (proxyRouteConfig, error)
// validateProxyRouteDomain enforces the tp-<name>.<suffix> shape for
// protocol=http-proxy routes without needing the configured domain suffix.
func validateProxyRouteDomain(domain string) error
// tunnelRouteConflict gains domainOnly: an http-proxy route claims a whole
// hostname, so its conflict check ignores path_prefix. Callers are createTunnel
// (id == "") and updateTunnel (id == the route being patched).
func (a *API) tunnelRouteConflict(ctx context.Context, id, domain, pathPrefix string, domainOnly bool) (bool, error)
tunnelRequest 与 tunnelUpdateRequest 新增字段(JSON 名与 spec §9 一致):
// tunnelRequest 追加
AuthMode string `json:"authMode"`
CredentialID string `json:"credentialId"`
SourceCIDRs []string `json:"sourceCIDRs"`
TargetCIDRs []string `json:"targetCIDRs"`
TargetPorts []int `json:"targetPorts"`
AllowPrivateTargets *bool `json:"allowPrivateTargets"`
MaxConcurrentTunnels *int `json:"maxConcurrentTunnels"`
Description string `json:"description"`
// tunnelUpdateRequest 追加(全指针,PATCH 才能区分“未提供”与“显式清空”)
AuthMode *string `json:"authMode"`
CredentialID *string `json:"credentialId"`
SourceCIDRs *[]string `json:"sourceCIDRs"`
TargetCIDRs *[]string `json:"targetCIDRs"`
TargetPorts *[]int `json:"targetPorts"`
AllowPrivateTargets *bool `json:"allowPrivateTargets"`
MaxConcurrentTunnels *int `json:"maxConcurrentTunnels"`
Description *string `json:"description"`
credentialRequest 追加 Username string \json:”username”`;credentialResponse 追加 Username string `json:”username”``。
- Step 1: 写失败测试
internal/server/api_proxy_route_test.go:
package server
import (
"encoding/json"
"net/http"
"testing"
)
// createProxyTestAgent registers one Agent and returns its ID; every proxy
// route needs an egress Agent.
func createProxyTestAgent(t *testing.T, h http.Handler, token, name string) string {
t.Helper()
created := apiJSON(t, h, http.MethodPost, "/api/v1/agents", token, "proxy-agent-"+name, map[string]any{"name": name})
if created.Code != http.StatusCreated {
t.Fatalf("agent create status=%d: %s", created.Code, created.Body.String())
}
var agent struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(created.Body.Bytes(), &agent); err != nil {
t.Fatal(err)
}
return agent.Data.ID
}
// createProxyTestCredential registers one enabled proxy_basic credential.
func createProxyTestCredential(t *testing.T, h http.Handler, token, name, username, password string) string {
t.Helper()
created := apiJSON(t, h, http.MethodPost, "/api/v1/credentials", token, "proxy-cred-"+name, map[string]any{
"name": name, "type": "proxy_basic", "username": username, "enabled": true,
"secret": map[string]any{"password": password},
})
if created.Code != http.StatusCreated {
t.Fatalf("credential create status=%d: %s", created.Code, created.Body.String())
}
var cred struct {
Data struct {
ID string `json:"id"`
Username string `json:"username"`
HasSecret bool `json:"hasSecret"`
} `json:"data"`
}
if err := json.Unmarshal(created.Body.Bytes(), &cred); err != nil {
t.Fatal(err)
}
if cred.Data.Username != username || !cred.Data.HasSecret {
t.Fatalf("credential response = %+v", cred.Data)
}
return cred.Data.ID
}
type proxyRouteResponse struct {
Data struct {
ID string `json:"id"`
Protocol string `json:"protocol"`
Domain string `json:"domain"`
PathPrefix string `json:"pathPrefix"`
TargetHost string `json:"targetHost"`
TargetPort int `json:"targetPort"`
ProxyURL string `json:"proxyUrl"`
AuthMode string `json:"authMode"`
CredentialID string `json:"credentialId"`
SourceCIDRs []string `json:"sourceCIDRs"`
TargetCIDRs []string `json:"targetCIDRs"`
TargetPorts []int `json:"targetPorts"`
AllowPrivateTargets bool `json:"allowPrivateTargets"`
MaxConcurrentTunnels int `json:"maxConcurrentTunnels"`
Description string `json:"description"`
Status string `json:"status"`
} `json:"data"`
}
func TestAPICreatesProxyRouteWithSentinelTarget(t *testing.T) {
api, admin, _ := apiTestServer(t)
setCredentialSecretStore(t, api)
h := api.Handler()
token := apiToken(t, api, admin.Username, "admin-pass")
agentID := createProxyTestAgent(t, h, token, "egress")
credentialID := createProxyTestCredential(t, h, token, "proxy demo", "demo", "s3cret")
created := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-route-1", map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-demo.tm.example.com",
"authMode": "basic", "credentialId": credentialID,
"sourceCIDRs": []string{"11.71.85.0/24", "10.1.2.3"},
"targetCIDRs": []string{"10.10.0.0/16"}, "targetPorts": []int{443, 443, 8443},
"allowPrivateTargets": true, "maxConcurrentTunnels": 8, "description": "demo egress",
})
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d: %s", created.Code, created.Body.String())
}
var got proxyRouteResponse
if err := json.Unmarshal(created.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
d := got.Data
if d.Protocol != "http-proxy" || d.TargetHost != "*" || d.TargetPort != 0 || d.PathPrefix != "/" {
t.Fatalf("sentinel target not enforced: %+v", d)
}
if d.ProxyURL != "https://tp-demo.tm.example.com" {
t.Fatalf("proxyUrl = %q", d.ProxyURL)
}
if d.AuthMode != "basic" || d.CredentialID != credentialID || d.Description != "demo egress" {
t.Fatalf("policy fields = %+v", d)
}
if len(d.SourceCIDRs) != 2 || d.SourceCIDRs[1] != "10.1.2.3/32" {
t.Fatalf("bare IP was not normalized to a CIDR: %#v", d.SourceCIDRs)
}
if len(d.TargetPorts) != 2 || d.TargetPorts[0] != 443 || d.TargetPorts[1] != 8443 {
t.Fatalf("target ports not deduped and sorted: %#v", d.TargetPorts)
}
if !d.AllowPrivateTargets || d.MaxConcurrentTunnels != 8 {
t.Fatalf("policy switches = %+v", d)
}
if json.Valid(created.Body.Bytes()) == false {
t.Fatal("response is not valid JSON")
}
}
// tp-* 域名是“整机代理端点”,一条域名只能有一条路由。这个用例锁住三层语义:
// 大小写变体算同一条、同域名的路径级反代路由不允许共存、update 改域名同样受限。
func TestAPIProxyRouteRejectsDuplicateDomain(t *testing.T) {
api, admin, _ := apiTestServer(t)
setCredentialSecretStore(t, api)
h := api.Handler()
token := apiToken(t, api, admin.Username, "admin-pass")
agentID := createProxyTestAgent(t, h, token, "egress-dup")
first := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-dup-1", map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-dup.tm.example.com",
"authMode": "none", "sourceCIDRs": []string{"10.0.0.0/8"},
})
if first.Code != http.StatusCreated {
t.Fatalf("create status=%d: %s", first.Code, first.Body.String())
}
var created proxyRouteResponse
if err := json.Unmarshal(first.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
upper := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-dup-2", map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "TP-DUP.tm.example.com",
"authMode": "none", "sourceCIDRs": []string{"10.0.0.0/8"},
})
if upper.Code != http.StatusConflict {
t.Fatalf("case-variant duplicate status=%d body=%s, want 409", upper.Code, upper.Body.String())
}
reverse := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-dup-3", map[string]any{
"agentId": agentID, "protocol": "http", "domain": "tp-dup.tm.example.com", "pathPrefix": "/admin",
"targetHost": "10.0.0.9", "targetPort": 8080,
})
if reverse.Code != http.StatusConflict {
t.Fatalf("reverse-proxy route on a tp-* domain status=%d body=%s, want 409", reverse.Code, reverse.Body.String())
}
other := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-dup-4", map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-other.tm.example.com",
"authMode": "none", "sourceCIDRs": []string{"10.0.0.0/8"},
})
if other.Code != http.StatusCreated {
t.Fatalf("second proxy route status=%d: %s", other.Code, other.Body.String())
}
var otherRoute proxyRouteResponse
if err := json.Unmarshal(other.Body.Bytes(), &otherRoute); err != nil {
t.Fatal(err)
}
if otherRoute.Data.ID == created.Data.ID {
t.Fatal("two distinct proxy routes share an ID")
}
renamed := apiJSON(t, h, http.MethodPatch, "/api/v1/routes/"+otherRoute.Data.ID, token, "", map[string]any{
"domain": "tp-dup.tm.example.com",
})
if renamed.Code != http.StatusConflict {
t.Fatalf("rename onto an existing tp-* domain status=%d body=%s, want 409", renamed.Code, renamed.Body.String())
}
}
// 反向顺序也必须被拒:反代路由先占用 tp-* 域名时,后建的 proxy 路由不能成功,
// 否则它会被 nginx 的精确 server_name 永久遮蔽,且后台看不出原因。
func TestAPIProxyRouteRejectsDomainClaimedByReverseProxy(t *testing.T) {
api, admin, _ := apiTestServer(t)
setCredentialSecretStore(t, api)
h := api.Handler()
token := apiToken(t, api, admin.Username, "admin-pass")
agentID := createProxyTestAgent(t, h, token, "egress-shadow")
reverse := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-shadow-1", map[string]any{
"agentId": agentID, "protocol": "http", "domain": "tp-shadow.tm.example.com", "pathPrefix": "/admin",
"targetHost": "10.0.0.9", "targetPort": 8080,
})
if reverse.Code != http.StatusCreated {
t.Fatalf("reverse-proxy create status=%d: %s", reverse.Code, reverse.Body.String())
}
shadowed := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-shadow-2", map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-shadow.tm.example.com",
"authMode": "none", "sourceCIDRs": []string{"10.0.0.0/8"},
})
if shadowed.Code != http.StatusConflict {
t.Fatalf("proxy route on a claimed domain status=%d body=%s, want 409", shadowed.Code, shadowed.Body.String())
}
}
func TestAPIProxyRouteValidationFailures(t *testing.T) {
api, admin, _ := apiTestServer(t)
setCredentialSecretStore(t, api)
h := api.Handler()
token := apiToken(t, api, admin.Username, "admin-pass")
agentID := createProxyTestAgent(t, h, token, "egress-bad")
credentialID := createProxyTestCredential(t, h, token, "proxy bad", "demo", "s3cret")
base := func() map[string]any {
return map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-ok.tm.example.com",
"authMode": "none",
}
}
cases := []struct {
name string
mutate func(map[string]any)
}{
{"missing tp- prefix", func(m map[string]any) { m["domain"] = "demo.tm.example.com" }},
{"missing suffix", func(m map[string]any) { m["domain"] = "tp-demo" }},
{"underscore in name", func(m map[string]any) { m["domain"] = "tp-de_mo.tm.example.com" }},
{"empty name", func(m map[string]any) { m["domain"] = "tp-.tm.example.com" }},
{"name too long", func(m map[string]any) {
m["domain"] = "tp-0123456789012345678901234567890123.tm.example.com"
}},
{"client supplied targetHost", func(m map[string]any) { m["targetHost"] = "10.0.0.1" }},
{"client supplied targetPort", func(m map[string]any) { m["targetPort"] = 8080 }},
{"client supplied config map", func(m map[string]any) { m["config"] = map[string]any{"authMode": "none"} }},
{"basic without credential", func(m map[string]any) { m["authMode"] = "basic" }},
{"unknown credential", func(m map[string]any) {
m["authMode"] = "basic"
m["credentialId"] = "cred-does-not-exist"
}},
{"unknown auth mode", func(m map[string]any) { m["authMode"] = "digest" }},
{"invalid source cidr", func(m map[string]any) { m["sourceCIDRs"] = []string{"11.71.85.0/33"} }},
{"invalid target cidr", func(m map[string]any) { m["targetCIDRs"] = []string{"not-a-cidr"} }},
{"invalid target port", func(m map[string]any) { m["targetPorts"] = []int{70000} }},
{"negative concurrency", func(m map[string]any) { m["maxConcurrentTunnels"] = -1 }},
}
for i, tc := range cases {
body := base()
tc.mutate(body)
resp := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "", body)
if resp.Code != http.StatusBadRequest && resp.Code != http.StatusForbidden && resp.Code != http.StatusNotFound {
t.Fatalf("%s: status=%d body=%s", tc.name, resp.Code, resp.Body.String())
}
_ = i
}
}
func TestAPIProxyRouteRejectsForeignCredential(t *testing.T) {
api, admin, user := apiTestServer(t)
setCredentialSecretStore(t, api)
h := api.Handler()
adminToken := apiToken(t, api, admin.Username, "admin-pass")
userToken := apiToken(t, api, user.Username, "alice-pass")
agentID := createProxyTestAgent(t, h, adminToken, "egress-owner")
credentialID := createProxyTestCredential(t, h, adminToken, "admin proxy", "demo", "s3cret")
// The credential belongs to the admin, so a non-admin caller must not be
// able to bind it to their own route.
resp := apiJSON(t, h, http.MethodPost, "/api/v1/routes", userToken, "proxy-foreign-cred", map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-alice.tm.example.com",
"authMode": "basic", "credentialId": credentialID,
})
if resp.Code != http.StatusForbidden && resp.Code != http.StatusNotFound {
t.Fatalf("foreign credential status=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestAPIProxyRouteUpdateKeepsSentinelAndRejectsProtocolSwitch(t *testing.T) {
api, admin, _ := apiTestServer(t)
setCredentialSecretStore(t, api)
h := api.Handler()
token := apiToken(t, api, admin.Username, "admin-pass")
agentID := createProxyTestAgent(t, h, token, "egress-update")
created := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-route-upd", map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-upd.tm.example.com",
"authMode": "none", "sourceCIDRs": []string{"10.0.0.0/8"},
})
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d: %s", created.Code, created.Body.String())
}
var route proxyRouteResponse
if err := json.Unmarshal(created.Body.Bytes(), &route); err != nil {
t.Fatal(err)
}
patched := apiJSON(t, h, http.MethodPatch, "/api/v1/routes/"+route.Data.ID, token, "", map[string]any{
"sourceCIDRs": []string{}, "allowPrivateTargets": false, "status": "disabled",
"description": "closed for now",
})
if patched.Code != http.StatusOK {
t.Fatalf("patch status=%d: %s", patched.Code, patched.Body.String())
}
var updated proxyRouteResponse
if err := json.Unmarshal(patched.Body.Bytes(), &updated); err != nil {
t.Fatal(err)
}
if updated.Data.TargetHost != "*" || updated.Data.TargetPort != 0 {
t.Fatalf("sentinel lost on update: %+v", updated.Data)
}
if len(updated.Data.SourceCIDRs) != 0 {
t.Fatalf("sourceCIDRs not cleared: %#v", updated.Data.SourceCIDRs)
}
if updated.Data.AllowPrivateTargets || updated.Data.Status != "disabled" || updated.Data.Description != "closed for now" {
t.Fatalf("patched fields = %+v", updated.Data)
}
switched := apiJSON(t, h, http.MethodPatch, "/api/v1/routes/"+route.Data.ID, token, "", map[string]any{"protocol": "http"})
if switched.Code != http.StatusBadRequest {
t.Fatalf("protocol switch away from http-proxy status=%d body=%s", switched.Code, switched.Body.String())
}
toProxy := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "plain-route", map[string]any{
"agentId": agentID, "protocol": "http", "domain": "app.tm.example.com", "pathPrefix": "/",
"targetHost": "10.0.0.9", "targetPort": 8080,
})
if toProxy.Code != http.StatusCreated {
t.Fatalf("plain route create status=%d: %s", toProxy.Code, toProxy.Body.String())
}
var plain proxyRouteResponse
if err := json.Unmarshal(toProxy.Body.Bytes(), &plain); err != nil {
t.Fatal(err)
}
upgraded := apiJSON(t, h, http.MethodPatch, "/api/v1/routes/"+plain.Data.ID, token, "", map[string]any{"protocol": "http-proxy"})
if upgraded.Code != http.StatusBadRequest {
t.Fatalf("protocol switch to http-proxy status=%d body=%s", upgraded.Code, upgraded.Body.String())
}
}
func TestAPIProxyRouteIdempotentReplay(t *testing.T) {
api, admin, _ := apiTestServer(t)
setCredentialSecretStore(t, api)
h := api.Handler()
token := apiToken(t, api, admin.Username, "admin-pass")
agentID := createProxyTestAgent(t, h, token, "egress-idem")
body := map[string]any{
"agentId": agentID, "protocol": "http-proxy", "domain": "tp-idem.tm.example.com", "authMode": "none",
}
first := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-idem-key", body)
if first.Code != http.StatusCreated {
t.Fatalf("first status=%d: %s", first.Code, first.Body.String())
}
replay := apiJSON(t, h, http.MethodPost, "/api/v1/routes", token, "proxy-idem-key", body)
if replay.Code != http.StatusCreated {
t.Fatalf("replay status=%d: %s", replay.Code, replay.Body.String())
}
var a, b proxyRouteResponse
if err := json.Unmarshal(first.Body.Bytes(), &a); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(replay.Body.Bytes(), &b); err != nil {
t.Fatal(err)
}
if a.Data.ID != b.Data.ID {
t.Fatalf("idempotent replay created a second route: %s vs %s", a.Data.ID, b.Data.ID)
}
}
func TestNormalizeProxyRouteConfigDefaults(t *testing.T) {
encoded, message := normalizeProxyRouteConfig(proxyRouteConfig{})
if message != "" {
t.Fatalf("empty policy must be valid, got %q", message)
}
decoded, err := proxyRouteConfigOf(encoded)
if err != nil {
t.Fatal(err)
}
if decoded.AuthMode != "none" || decoded.AllowPrivateTargets == nil || !*decoded.AllowPrivateTargets {
t.Fatalf("defaults = %+v", decoded)
}
if decoded.SourceCIDRs == nil || decoded.TargetCIDRs == nil || decoded.TargetPorts == nil {
t.Fatalf("empty allowlists must round-trip as [] not null: %s", encoded)
}
if _, message := normalizeProxyRouteConfig(proxyRouteConfig{AuthMode: "basic"}); message == "" {
t.Fatal("basic without credentialId must be rejected")
}
if _, message := normalizeProxyRouteConfig(proxyRouteConfig{Description: string(make([]byte, 300))}); message == "" {
t.Fatal("oversized description must be rejected")
}
}
internal/server/credential_api_test.go 追加:
func TestCredentialAPIProxyBasicNeverEchoesPassword(t *testing.T) {
api, _, user := apiTestServer(t)
setCredentialSecretStore(t, api)
token := apiToken(t, api, user.Username, "alice-pass")
h := api.Handler()
created := apiJSON(t, h, http.MethodPost, "/api/v1/credentials", token, "cred-proxy-basic", map[string]any{
"name": "proxy demo", "type": "proxy_basic", "username": "demo", "enabled": true,
"secret": map[string]any{"password": "s3cret"},
})
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d: %s", created.Code, created.Body.String())
}
if strings.Contains(created.Body.String(), "s3cret") {
t.Fatalf("password leaked into the response: %s", created.Body.String())
}
var envelope struct {
Data struct {
ID string `json:"id"`
Type string `json:"type"`
Username string `json:"username"`
PublicKey string `json:"publicKey"`
HasSecret bool `json:"hasSecret"`
} `json:"data"`
}
if err := json.Unmarshal(created.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Data.Username != "demo" || envelope.Data.PublicKey != "demo" || !envelope.Data.HasSecret {
t.Fatalf("response = %+v", envelope.Data)
}
missing := apiJSON(t, h, http.MethodPost, "/api/v1/credentials", token, "cred-proxy-basic-no-user", map[string]any{
"name": "no username", "type": "proxy_basic", "enabled": true,
"secret": map[string]any{"password": "s3cret"},
})
if missing.Code != http.StatusBadRequest {
t.Fatalf("proxy_basic without username status=%d body=%s", missing.Code, missing.Body.String())
}
noSecret := apiJSON(t, h, http.MethodPost, "/api/v1/credentials", token, "cred-proxy-basic-no-secret", map[string]any{
"name": "no secret", "type": "proxy_basic", "username": "demo2", "enabled": true,
})
if noSecret.Code != http.StatusBadRequest {
t.Fatalf("proxy_basic without password status=%d body=%s", noSecret.Code, noSecret.Body.String())
}
}
- Step 2: 运行测试确认失败
Run: go test ./internal/server/ -run 'ProxyRoute|ProxyBasic|NormalizeProxyRouteConfig' -count=1
Expected: FAIL,undefined: normalizeProxyRouteConfig、undefined: proxyRouteConfigOf(编译期即失败)。补上两个函数后,HTTP 用例仍失败:创建返回 400 agentId, targetHost and valid targetPort are required,因为哨兵特例还没加。
- Step 3: 最小实现
(a) internal/server/proxy_entry_routes.go
把 Task 7 loadProxyRoutes 里的匿名 config 结构替换为具名 proxyRouteConfig(字段与 Task 7 一致,另加 Description),loadProxyRoutes 改为:
cfg, err := proxyRouteConfigOf(tunnel.Config)
if err != nil {
return nil, errors.New("proxy route config is invalid: " + tunnel.ID)
}
新增:
var proxyRouteNamePattern = regexp.MustCompile(`^tp-[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?\..+$`)
func proxyRouteConfigOf(raw string) (proxyRouteConfig, error) {
cfg := proxyRouteConfig{AuthMode: proxyentry.AuthModeNone, AllowPrivateTargets: boolPtr(true)}
trimmed := strings.TrimSpace(raw)
if trimmed != "" && trimmed != "{}" {
if err := json.Unmarshal([]byte(trimmed), &cfg); err != nil {
return proxyRouteConfig{}, err
}
}
if cfg.AllowPrivateTargets == nil {
cfg.AllowPrivateTargets = boolPtr(true)
}
if strings.TrimSpace(cfg.AuthMode) == "" {
cfg.AuthMode = proxyentry.AuthModeNone
}
return cfg, nil
}
func normalizeProxyRouteConfig(in proxyRouteConfig) (string, string) {
authMode := strings.ToLower(strings.TrimSpace(in.AuthMode))
if authMode == "" {
authMode = proxyentry.AuthModeNone
}
if authMode != proxyentry.AuthModeNone && authMode != proxyentry.AuthModeBasic {
return "", "authMode must be none or basic"
}
credentialID := strings.TrimSpace(in.CredentialID)
if authMode == proxyentry.AuthModeBasic && credentialID == "" {
return "", "credentialId is required when authMode is basic"
}
if authMode == proxyentry.AuthModeNone {
credentialID = ""
}
sourceCIDRs, message := normalizeCIDRList(in.SourceCIDRs, "sourceCIDRs")
if message != "" {
return "", message
}
targetCIDRs, message := normalizeCIDRList(in.TargetCIDRs, "targetCIDRs")
if message != "" {
return "", message
}
targetPorts := normalizePortList(in.TargetPorts)
for _, port := range in.TargetPorts {
if port < 1 || port > 65535 {
return "", "targetPorts must be between 1 and 65535"
}
}
if in.MaxConcurrentTunnels < 0 {
return "", "maxConcurrentTunnels must be >= 0"
}
description := strings.TrimSpace(in.Description)
if len(description) > 256 {
return "", "description must be at most 256 characters"
}
allowPrivate := true
if in.AllowPrivateTargets != nil {
allowPrivate = *in.AllowPrivateTargets
}
// Reuse the runtime policy so an accepted write can never produce a route
// the data plane would refuse to build.
if _, err := proxyentry.NewTargetPolicy(targetCIDRs, targetPorts, allowPrivate); err != nil {
return "", "target policy is invalid: " + err.Error()
}
if _, err := proxyentry.NewSourceACL(sourceCIDRs); err != nil && len(sourceCIDRs) > 0 {
return "", "sourceCIDRs are invalid"
}
out := proxyRouteConfig{
AuthMode: authMode, CredentialID: credentialID,
SourceCIDRs: sourceCIDRs, TargetCIDRs: targetCIDRs, TargetPorts: targetPorts,
AllowPrivateTargets: &allowPrivate, MaxConcurrentTunnels: in.MaxConcurrentTunnels,
Description: description,
}
encoded, err := json.Marshal(out)
if err != nil {
return "", "invalid proxy route config"
}
return string(encoded), ""
}
// normalizeCIDRList accepts bare IPs by adding the host mask, drops duplicates
// and keeps the caller's order so the UI list stays stable.
func normalizeCIDRList(raw []string, field string) ([]string, string) {
out := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
normalized, err := proxyentry.NormalizeCIDR(item)
if err != nil {
return nil, field + " contains an invalid entry: " + strings.TrimSpace(item)
}
if _, dup := seen[normalized]; dup {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out, ""
}
// normalizePortList dedupes and sorts so two equivalent writes produce
// byte-identical config JSON, which keeps idempotent replays comparable.
func normalizePortList(raw []int) []int {
out := make([]int, 0, len(raw))
seen := make(map[int]struct{}, len(raw))
for _, port := range raw {
if _, dup := seen[port]; dup {
continue
}
seen[port] = struct{}{}
out = append(out, port)
}
sort.Ints(out)
return out
}
func validateProxyRouteDomain(domain string) error {
if !proxyRouteNamePattern.MatchString(domain) {
return errors.New("http-proxy domain must look like tp-<name>.<suffix> with a lowercase name of at most 32 characters")
}
return nil
}
normalizeCIDRList / normalizePortList 若仓库已有等价实现(例如 agent policy 的 CIDR 归一化),改为复用既有函数,不新增第二份实现;本步骤先按新增写,Step 5 的复审确认无重复后再定稿。
(b) internal/server/api.go:createTunnel
在 decodeJSON 之后、既有必填校验之前插入 proxy 分支:
protocol := strings.ToLower(strings.TrimSpace(req.Protocol))
isProxyRoute := protocol == storage.ProtocolHTTPProxy
if isProxyRoute {
if req.AgentID == "" {
writeAPIError(w, http.StatusBadRequest, "agentId is required")
return
}
if strings.TrimSpace(req.TargetHost) != "" && strings.TrimSpace(req.TargetHost) != storage.ProxyTargetWildcard {
writeAPIError(w, http.StatusBadRequest, "targetHost must be omitted for http-proxy routes")
return
}
if req.TargetPort != 0 {
writeAPIError(w, http.StatusBadRequest, "targetPort must be omitted for http-proxy routes")
return
}
if len(req.Config) > 0 {
writeAPIError(w, http.StatusBadRequest, "http-proxy routes use dedicated fields; config must be omitted")
return
}
req.Domain = strings.ToLower(strings.TrimSpace(req.Domain))
if err := routing.ValidateDomainPattern(req.Domain); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateProxyRouteDomain(req.Domain); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if _, err := a.service.GetAgent(r.Context(), req.AgentID); err != nil {
writeStorageError(w, err)
return
}
encoded, status, message := a.buildProxyRoutePolicy(r.Context(), p, proxyRouteConfig{
AuthMode: req.AuthMode, CredentialID: req.CredentialID,
SourceCIDRs: req.SourceCIDRs, TargetCIDRs: req.TargetCIDRs, TargetPorts: req.TargetPorts,
AllowPrivateTargets: req.AllowPrivateTargets, MaxConcurrentTunnels: concurrencyOrZero(req.MaxConcurrentTunnels),
Description: req.Description,
})
if message != "" {
writeAPIError(w, status, message)
return
}
req.Protocol = storage.ProtocolHTTPProxy
req.PathPrefix = "/"
req.TargetHost = storage.ProxyTargetWildcard
req.TargetPort = 0
req.Config = nil
// Fall through to the shared conflict check and audit path with the
// sentinel already in place.
proxyConfigJSON = encoded
}
其中 proxyConfigJSON 是分支前声明的 string(var proxyConfigJSON string),concurrencyOrZero(*int) int 是 nil 安全取值件,随后替换既有的 configBytes, message := normalizeUpstreamRouteConfig(config):
configBytes := proxyConfigJSON
if !isProxyRoute {
config := req.Config
if config == nil {
config = map[string]any{}
}
config["hostHeader"] = strings.TrimSpace(req.HostHeader)
config["targetScheme"] = strings.ToLower(strings.TrimSpace(req.TargetScheme))
config["tlsServerName"] = strings.TrimSpace(req.TLSServerName)
var message string
configBytes, message = normalizeUpstreamRouteConfig(config)
if message != "" {
writeAPIError(w, http.StatusBadRequest, message)
return
}
}
凭据归属校验与 config 归一化拆成两个函数:创建路径用组合版 buildProxyRoutePolicy,更新路径单独复用 validateProxyCredential(更新时策略来自“已存配置 + 指针覆盖”,不能整块重建)。Handler 不直接查库,凭据一律走 credentialService:
// validateProxyCredential checks that a basic-auth route references a
// proxy_basic credential the caller may actually see. The message is empty on
// success; status is the HTTP code to use when it is not. Unknown and
// unauthorized IDs share one message so credential IDs cannot be enumerated.
func (a *API) validateProxyCredential(ctx context.Context, p auth.Principal, authMode, credentialID string) (int, string) {
if strings.ToLower(strings.TrimSpace(authMode)) != proxyentry.AuthModeBasic {
return 0, ""
}
if a.credentialService == nil {
return http.StatusServiceUnavailable, "credential service unavailable"
}
id := strings.TrimSpace(credentialID)
if id == "" {
return http.StatusBadRequest, "credentialId is required when authMode is basic"
}
credential, err := a.credentialService.Get(ctx, p, id)
if err != nil {
return credentialErrorStatus(err), "credential is not usable"
}
if credential.Type != storage.CredentialTypeProxyBasic || !credential.Enabled || credential.DeletedAt != nil {
return http.StatusBadRequest, "credentialId must reference an enabled proxy_basic credential"
}
return 0, ""
}
// buildProxyRoutePolicy is the create-path composition: validate the credential
// reference, then normalize the whole policy into the stored config JSON.
func (a *API) buildProxyRoutePolicy(ctx context.Context, p auth.Principal, policy proxyRouteConfig) (string, int, string) {
status, message := a.validateProxyCredential(ctx, p, policy.AuthMode, policy.CredentialID)
if message != "" {
return "", status, message
}
encoded, message := normalizeProxyRouteConfig(policy)
if message != "" {
return "", http.StatusBadRequest, message
}
return encoded, 0, ""
}
func concurrencyOrZero(v *int) int {
if v == nil {
return 0
}
return *v
}
credentialErrorStatus(err) 复用 credential_api.go 中 writeCredentialError 已有的错误到状态码映射:把那段 switch 抽成 credentialErrorStatus(err) int,writeCredentialError 改为调用它,避免两份映射。未找到与无权访问都必须映射为 403/404(不得回 200),并统一对外文案 credential is not usable,防止凭据 ID 枚举。
路由域名冲突判定:http-proxy 按域名整体互斥(不看 path_prefix)
仓库现状(main @ b3acd34,已核实;internal/server/api.go 自 f0ca22c 起未变更,行号同样适用):a.tunnelRouteConflict(ctx, id, domain, pathPrefix)(internal/server/api.go:1246,updateTunnel 调用)与 createTunnel(internal/server/api.go:1262)里内联的同语义循环(internal/server/api.go:1287)都用 strings.EqualFold(route.Domain, domain) && route.PathPrefix == pathPrefix 判冲突;tunnels 表另有 UNIQUE(domain, path_prefix)。
这个条件对反代路由是正确的(同域名不同前缀是合法的多路径路由),但对 http-proxy 不成立:tp-* 域名的 path_prefix 恒为 "/" 且没有语义,它是一个整机代理端点。若允许它与同域名的路径级反代路由共存,nginx 的精确 server_name 会优先于 tp-* 的正则 server_name,那条 proxy 路由永远不可达,而 UNIQUE(domain, path_prefix) 也拦不住(两行前缀不同)。因此必须在应用层按域名整体互斥。
改法(两处重复 + 本次新增语义 = 三处,按 DRY 收敛成一个函数):
tunnelRouteConflict增加domainOnly bool形参:为 true 时只比较strings.EqualFold(route.Domain, domain),跳过PathPrefix比较;domain == ""仍直接返回false, nil。createTunnel删掉 1287 起的内联循环,改调a.tunnelRouteConflict(r.Context(), "", req.Domain, req.PathPrefix, isProxyRoute)。id传空串代表新建,既有route.ID != id判断对空串天然成立;冲突时沿用既有文案与状态码writeAPIError(w, http.StatusConflict, "route already exists"),不新增错误码。updateTunnel的调用处传domainOnly = current.Protocol == storage.ProtocolHTTPProxy || protocol == storage.ProtocolHTTPProxy。协议切换已被 (d) 的守卫拦掉,写全两侧只是让“任一端是 proxy 路由”的语义显式,避免以后放开切换时漏改。- 不新增查询:
listAllTunnels的调用次数与既有一致(create 路径本来就调了一次)。
集群模式下多个 Server 并发创建时 a.routeMu 只在进程内生效,跨节点竞争最终由 UNIQUE(domain, path_prefix) 兜底,writeStorageError 已把含 unique / constraint 的驱动错误映射为 409。注意两点:该兜底只覆盖前缀也相同的情况,跨协议的域名遮蔽必须靠上面的 domainOnly 判定在进库前拦住;兜底会把驱动原文写进 msg,这是所有路由共享的既有行为,本计划不改(改动会牵动全部路由的错误契约,属于独立议题)。
(c) internal/server/api.go:apiService.CreateTunnel
func (s *apiService) CreateTunnel(ctx context.Context, v storage.Tunnel) (storage.Tunnel, error) {
if v.Protocol == storage.ProtocolHTTPProxy {
// The real target comes from each proxied request, so the sentinel
// target is the only valid stored value.
if v.AgentID == "" || v.TargetHost != storage.ProxyTargetWildcard || v.TargetPort != 0 {
return storage.Tunnel{}, errors.New("http-proxy routes require an agentId and the wildcard sentinel target")
}
} else if v.AgentID == "" || v.TargetHost == "" || v.TargetPort < 1 || v.TargetPort > 65535 {
return storage.Tunnel{}, errors.New("agentId, targetHost and valid targetPort are required")
}
...
}
其余部分(ValidateDomainPattern、Create、回读最新行)保持不变。
(d) internal/server/api.go:updateTunnel
full(PUT)必填校验:protocol == http-proxy时只要求AgentID,不要求TargetHost/TargetPort。req.Protocol != nil分支的允许值加上storage.ProtocolHTTPProxy;并在解析出protocol后立即判断“协议切换”:
if next.Protocol != current.Protocol && (protocol == storage.ProtocolHTTPProxy || current.Protocol == storage.ProtocolHTTPProxy) {
writeAPIError(w, http.StatusBadRequest, "protocol cannot be changed to or from http-proxy; create a new route")
return
}
- 在既有
req.TargetHost/req.TargetPort校验之前加 proxy 守卫:
isProxyRoute := current.Protocol == storage.ProtocolHTTPProxy
if isProxyRoute {
if req.TargetHost != nil && strings.TrimSpace(*req.TargetHost) != storage.ProxyTargetWildcard {
writeAPIError(w, http.StatusBadRequest, "targetHost is fixed for http-proxy routes")
return
}
if req.TargetPort != nil && *req.TargetPort != 0 {
writeAPIError(w, http.StatusBadRequest, "targetPort is fixed for http-proxy routes")
return
}
if req.Config != nil || req.HostHeader != nil || req.TargetScheme != nil || req.TLSServerName != nil {
writeAPIError(w, http.StatusBadRequest, "http-proxy routes use dedicated fields; config, hostHeader, targetScheme and tlsServerName must be omitted")
return
}
next.TargetHost = storage.ProxyTargetWildcard
next.TargetPort = 0
next.PathPrefix = "/"
}
- proxy 路由的策略字段更新:先用
proxyRouteConfigOf(next.Config)取当前策略,再按指针逐字段覆盖,最后normalizeProxyRouteConfig写回。凭据变化时同样走buildProxyRoutePolicy的凭据校验(把该方法拆成validateProxyCredential(ctx, p, authMode, credentialID) (int, string)与normalizeProxyRouteConfig两步,update 分支只在前者通过后才归一化)。
if isProxyRoute && (req.AuthMode != nil || req.CredentialID != nil || req.SourceCIDRs != nil ||
req.TargetCIDRs != nil || req.TargetPorts != nil || req.AllowPrivateTargets != nil ||
req.MaxConcurrentTunnels != nil || req.Description != nil) {
// `policy` is deliberately not named `current`: updateTunnel already takes
// a `current storage.Tunnel` parameter, and shadowing it would silently
// change the meaning of every later `current.` reference.
policy, err := proxyRouteConfigOf(next.Config)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "stored proxy route config is invalid")
return
}
if req.AuthMode != nil {
policy.AuthMode = strings.ToLower(strings.TrimSpace(*req.AuthMode))
}
if req.CredentialID != nil {
policy.CredentialID = strings.TrimSpace(*req.CredentialID)
}
if status, message := a.validateProxyCredential(r.Context(), p, policy.AuthMode, policy.CredentialID); message != "" {
writeAPIError(w, status, message)
return
}
if req.SourceCIDRs != nil {
policy.SourceCIDRs = *req.SourceCIDRs
}
if req.TargetCIDRs != nil {
policy.TargetCIDRs = *req.TargetCIDRs
}
if req.TargetPorts != nil {
policy.TargetPorts = *req.TargetPorts
}
if req.AllowPrivateTargets != nil {
policy.AllowPrivateTargets = req.AllowPrivateTargets
}
if req.MaxConcurrentTunnels != nil {
policy.MaxConcurrentTunnels = *req.MaxConcurrentTunnels
}
if req.Description != nil {
policy.Description = *req.Description
}
encoded, message := normalizeProxyRouteConfig(policy)
if message != "" {
writeAPIError(w, http.StatusBadRequest, message)
return
}
next.Config = encoded
}
- 既有
req.Config != nil || req.HostHeader != nil || ...的 upstream 归一化块必须包在if !isProxyRoute { ... }中,否则会用normalizeUpstreamRouteConfig覆盖 proxy 策略 JSON。
(e) internal/server/api.go:publicTunnel
func publicTunnel(v storage.Tunnel) map[string]any {
if v.Protocol == storage.ProtocolHTTPProxy {
return publicProxyTunnel(v)
}
... 既有实现不变 ...
}
// publicProxyTunnel renders a tp-* route. The sentinel target is exposed so the
// UI can show why targetHost/targetPort are not editable, and proxyURL is
// derived from the stored domain so it needs no server configuration.
func publicProxyTunnel(v storage.Tunnel) map[string]any {
cfg, err := proxyRouteConfigOf(v.Config)
if err != nil {
cfg = proxyRouteConfig{AuthMode: proxyentry.AuthModeNone, AllowPrivateTargets: boolPtr(true)}
}
allowPrivate := true
if cfg.AllowPrivateTargets != nil {
allowPrivate = *cfg.AllowPrivateTargets
}
return map[string]any{
"id": v.ID, "agentId": v.AgentID, "protocol": v.Protocol, "domain": v.Domain,
"pathPrefix": v.PathPrefix, "targetHost": v.TargetHost, "targetPort": v.TargetPort,
"publicPort": v.PublicPort, "status": v.Status,
"proxyUrl": "https://" + v.Domain,
"authMode": cfg.AuthMode, "credentialId": cfg.CredentialID,
"sourceCIDRs": cfg.SourceCIDRs, "targetCIDRs": cfg.TargetCIDRs, "targetPorts": cfg.TargetPorts,
"allowPrivateTargets": allowPrivate, "maxConcurrentTunnels": cfg.MaxConcurrentTunnels,
"description": cfg.Description,
"createdAt": v.CreatedAt, "updatedAt": v.UpdatedAt,
}
}
hostHeader / targetScheme / tlsServerName 对 proxy 路由无意义,publicProxyTunnel 不返回这三个键(不要复用 publicUpstreamRouteConfig,否则会把哨兵 "*" 当成默认 HostHeader 与 TLSServerName 回显给前端)。
(f) internal/server/credential_api.go
credentialRequest加Username string \json:”username”``。handleCredentialListCreate的 POST 分支:input.Username = strings.TrimSpace(req.Username)。credentialPatchFromRequest:if strings.TrimSpace(req.Username) != "" { username := strings.TrimSpace(req.Username); patch.Username = &username }。credentialResponse加Username string \json:”username”`;publicCredential设置Username: credentialUsername(v)`,其中
// credentialUsername exposes the non-secret half of a proxy_basic credential.
// Every other type keeps publicKey as its only identifier.
func credentialUsername(v storage.Credential) string {
if v.Type == storage.CredentialTypeProxyBasic {
return v.PublicKey
}
return ""
}
-
凭据类型校验(
proxy_basic必须有 username 与 password、不得携带 privateKey/passphrase)由 Task 8 的validateCredentialShape负责,本任务只打通传输层,不重复实现。 -
Step 4: 同步 OpenAPI
docs/api/openapi.yaml:
components.schemas.TunnelRequest:protocol的 enum 改为[http, websocket, http-proxy];required改为[agentId]并在targetHost/targetPort的description里写明“http-proxy路由必须省略,服务端强制写入哨兵*/0”;新增authMode(enum[none, basic], defaultnone)、credentialId、sourceCIDRs(type: array, items: {type: string})、targetCIDRs、targetPorts(items: {type: integer, minimum: 1, maximum: 65535})、allowPrivateTargets(type: boolean, default: true)、maxConcurrentTunnels(type: integer, minimum: 0)、description(maxLength: 256)。components.schemas.TunnelUpdateRequest:同样把protocolenum 加上http-proxy,并新增上述 8 个字段(全部可选,语义为“省略即保持”);在description中写明协议不得在http-proxy与其它值之间切换。/api/v1/routes的post.responses增加'400': {description: Invalid proxy route policy, credential or domain shape};/api/v1/routes/{routeId}的patch.responses增加'400': {description: Invalid field, or a protocol switch involving http-proxy}。post与patch的'409'响应 description 补一句http-proxy的域名互斥语义:一条 tp-* 域名只能对应一条路由,与同域名的路径级反代路由(任意pathPrefix)冲突时返回 409route already exists;大小写变体视为同一条。components.schemas.CredentialCreateRequest/CredentialPatchRequest/Credential的typeenum 改为[ssh_public_key, password, proxy_basic];三者新增username: {type: string, maxLength: 255, description: proxy_basic only; stored in publicKey and never secret};CredentialSecretInput.password的 description 补上 “Required forpasswordandproxy_basic”。-
新增一节说明 407/403/502/503/504 由代理入口(非
/api/v1)返回,指向docs/user-guide/http-proxy-entry.md的错误码表,避免读者以为管理 API 会返回 407。 - Step 5: 运行测试确认通过
Run: go test ./internal/server/ ./internal/storage/ -count=1 && go test -race ./internal/server/ -count=1 && go vet ./internal/server/ && gofmt -l internal/ cmd/
Expected: PASS,gofmt -l 无输出。特别确认既有 TestAPIRouteUpstreamDomainAndTLSConfig、TestAPIAuthAndRBAC、credential 相关测试与 managed route 测试全部不回归。
-
TestAPIProxyRouteRejectsDuplicateDomain与TestAPIProxyRouteRejectsDomainClaimedByReverseProxy必须 PASS;同时确认既有路由冲突用例(同域名同前缀 409、同域名不同前缀仍可创建)没有因为domainOnly参数而回归——反代路由的domainOnly恒为 false,语义与改动前完全一致。 -
Step 6: 复审确认无重复实现
检查 normalizeCIDRList / normalizePortList / credentialErrorStatus 是否与仓库既有函数重复,并确认 createTunnel 里原来那段内联的域名冲突循环已被删除、只剩 tunnelRouteConflict 一个实现:
Run: grep -rn "ParseCIDR\|normalizePorts\|sort.Ints" internal/server/ internal/routing/ internal/proxyentry/ | grep -v _test.go
Expected: 若发现等价的既有实现,删除本任务新增的版本并改为复用;否则保留并在代码注释中说明为何不能复用(例如既有实现的错误文案不适合作为 400 响应)。
- Step 7: Commit(需授权)
git add internal/server/api.go internal/server/proxy_entry_routes.go internal/server/credential_api.go internal/server/api_proxy_route_test.go internal/server/credential_api_test.go docs/api/openapi.yaml
git commit -m "feat(api): manage tp proxy routes and proxy_basic credentials"
Task 13: 管理后台(Routes.vue / Credentials.vue / i18n)
Files:
- Modify:
web/src/api/client.ts(ManagedRoute、ManagedRouteCreateInput、ManagedRouteUpdateInput) - Modify:
web/src/api/credentials.ts(CredentialType、CredentialInput、Credential) - Modify:
web/src/views/Routes.vue(类型切换表单、ACL 编辑、凭据选择、列表列、使用说明抽屉) - Modify:
web/src/views/Credentials.vue(proxy_basic类型与 username 字段) - Modify:
web/src/i18n/messages/zh-CN.ts、web/src/i18n/messages/en-US.ts(schema.ts由typeof zhCN推导,不改) - Modify:
web/src/tests/routes.spec.ts、web/src/tests/credentials-view.spec.ts、web/src/tests/i18n.spec.ts - Modify:
internal/server/web_dist(Step 5npm run build后经scripts/verify-web-embed.sh校验的 Go embed 产物) - Modify:
docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md(Step 6 偏差回写) - 不改:
web/src/tests/layout-overflow.spec.ts(自动扫描src/views/*.vue的守卫,见下方约束)
Interfaces:
- Consumes: Task 12 的 API 响应字段(
proxyUrl、authMode、credentialId、sourceCIDRs、targetCIDRs、targetPorts、allowPrivateTargets、maxConcurrentTunnels、description)、既有listCredentials、loadAgentsForSelection/filterAgents(web/src/views/token-form.ts)、DataState/PageHeader组件 - Produces: 无新导出符号;
ManagedRoute与ManagedRouteCreateInput扩展后的类型即前端契约。
约束:
web/src/tests/i18n.spec.ts第 44 行断言messageKeys(zhCN)与messageKeys(enUS)完全相等,新增键必须两个文件同时加,且键名逐字一致。web/src/tests/layout-overflow.spec.ts会自动扫描src/views/*.vue:新加的容器如果用display:grid必须显式声明grid-template-columns;表格列沿用min-width+fixed="right",不得引入整页横向滚动条。-
现有
form是reactive对象,字段全部为标量;ACL 与端口列表用string[]/number[]会破坏resetCreateForm的逐字段赋值风格,因此新增form.sourceCIDRs: string、form.targetCIDRs: string、form.targetPorts: string三个逗号分隔字符串,提交前在submit()里解析成数组,与agentDetail策略表单已有的 CIDR/端口输入风格一致。 - Step 1: 写失败测试
web/src/tests/routes.spec.ts 追加:
it('supports the tp-* http proxy entry route type', () => {
const source = readFileSync('src/views/Routes.vue', 'utf8')
for (const marker of [
'value="http-proxy"',
"t('routes.protocolHttpProxy')",
'form.authMode',
"value=\"basic\"",
'form.credentialId',
'listCredentials',
'proxy_basic',
'form.sourceCIDRs',
'form.allowPrivateTargets',
'form.maxConcurrentTunnels',
'proxyUrl',
"t('routes.proxyUsageTitle')",
'isProxyRoute',
'proxyDomainPreview',
]) expect(source, marker).toContain(marker)
})
it('validates proxy route input before submitting', () => {
const source = readFileSync('src/views/Routes.vue', 'utf8')
expect(source).toContain("t('routes.proxyNameInvalid')")
expect(source).toContain("t('routes.proxyCredentialRequired')")
expect(source).toContain("t('routes.proxyCIDRInvalid')")
expect(source).toContain("t('routes.proxyPortInvalid')")
// The sentinel target is owned by the server, so the form must never send it.
expect(source).toContain('buildProxyRouteInput')
expect(source).not.toMatch(/targetHost:\s*'\*'/)
})
it('sends proxy routes without upstream-only fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { id: 'r1' } }) })
vi.stubGlobal('fetch', fetchMock)
await createRoute({
agentId: 'agent-1',
domain: 'tp-demo.tm.example.com',
pathPrefix: '/',
protocol: 'http-proxy',
targetHost: '',
targetPort: 0,
authMode: 'basic',
credentialId: 'cred-1',
sourceCIDRs: ['11.71.85.0/24'],
targetCIDRs: [],
targetPorts: [443],
allowPrivateTargets: true,
maxConcurrentTunnels: 8,
description: 'demo',
}, 'proxy-key')
const call = fetchMock.mock.calls[0]
const body = JSON.parse(call[1].body as string)
expect(body.protocol).toBe('http-proxy')
expect(body.authMode).toBe('basic')
expect(body.credentialId).toBe('cred-1')
expect(body.sourceCIDRs).toEqual(['11.71.85.0/24'])
expect(body.targetPorts).toEqual([443])
expect(body).not.toHaveProperty('hostHeader')
expect(body).not.toHaveProperty('targetScheme')
expect(body).not.toHaveProperty('tlsServerName')
expect(body).not.toHaveProperty('config')
vi.unstubAllGlobals()
})
web/src/tests/credentials-view.spec.ts 追加:
it('supports the proxy_basic credential type', () => {
const source = readFileSync('src/views/Credentials.vue', 'utf8')
for (const marker of [
'value="proxy_basic"',
"t('credentials.types.proxyBasic')",
'form.username',
"t('credentials.username')",
"t('credentials.usernameRequired')",
]) expect(source, marker).toContain(marker)
// A proxy_basic password is write-only like every other secret.
expect(source).toContain('form.secretPassword')
expect(source).not.toContain('credential.password')
})
web/src/tests/i18n.spec.ts 追加(沿用文件内既有的 messageKeys 与遍历两份 messages 的写法):
it('translates every proxy entry key in both locales', () => {
for (const locale of [zhCN, enUS]) {
for (const key of [
'protocolHttpProxy', 'proxyName', 'proxyNameHelp', 'proxyDomainPreview', 'proxyAuthMode',
'proxyAuthNone', 'proxyAuthBasic', 'proxyCredential', 'proxyCreateCredential',
'proxySourceCIDRs', 'proxySourceCIDRsHelp', 'proxyAllowAll', 'proxyTargetCIDRs',
'proxyTargetPorts', 'proxyAllowPrivateTargets', 'proxyMaxConcurrentTunnels',
'proxyDescription', 'proxyUrl', 'proxyCopy', 'proxyCopied', 'proxyUsage', 'proxyUsageTitle',
'proxyActiveTunnelsHint', 'proxyTargetDynamic',
'proxyNameInvalid', 'proxyCredentialRequired', 'proxyCIDRInvalid', 'proxyPortInvalid',
]) expect(Object.keys(locale.routes).sort(), key).toContainEqual(key)
for (const key of ['proxyBasic', 'username', 'usernameRequired']) {
expect(Object.keys(locale.credentials).sort(), key).toContainEqual(key)
}
expect(Object.keys(locale.credentials.types).sort()).toContainEqual('proxyBasic')
}
})
- Step 2: 运行测试确认失败
Run: cd web && npm test -- --run
Expected: FAIL。新增三条 routes.spec.ts 用例与一条 credentials-view.spec.ts、一条 i18n.spec.ts 用例失败;createRoute 用例还会因 TypeScript 类型不接受 authMode 等字段而在 npm run build 阶段报类型错误。
- Step 3: 最小实现
(a) web/src/api/client.ts
export type ManagedRouteAuthMode = 'none' | 'basic'
export type ManagedRoute = {
// ...既有字段保持不变...
authMode?: ManagedRouteAuthMode
credentialId?: string
sourceCIDRs?: string[]
targetCIDRs?: string[]
targetPorts?: number[]
allowPrivateTargets?: boolean
maxConcurrentTunnels?: number
description?: string
// Read-only, derived server-side from the stored domain.
proxyUrl?: string
}
export type ManagedRouteCreateInput = {
agentId: string
domain: string
pathPrefix: string
protocol: string
targetHost: string
targetPort: number
hostHeader?: string
targetScheme?: string
tlsServerName?: string
authMode?: ManagedRouteAuthMode
credentialId?: string
sourceCIDRs?: string[]
targetCIDRs?: string[]
targetPorts?: number[]
allowPrivateTargets?: boolean
maxConcurrentTunnels?: number
description?: string
}
ManagedRouteUpdateInput 已是 Partial<ManagedRouteCreateInput & { status: string }>,无需改动。
(b) web/src/api/credentials.ts
export type CredentialType = 'ssh_public_key' | 'password' | 'proxy_basic'
Credential 追加 username?: string;CredentialInput 追加 username?: string。listCredentials 已支持 type 过滤,Routes.vue 用 listCredentials({ type: 'proxy_basic', status: 'active', limit: 200 }) 取候选凭据。
(c) web/src/views/Routes.vue
protocol的el-select增加<el-option :label="t('routes.protocolHttpProxy')" value="http-proxy" />。- 新增
const isProxyRoute = computed(() => form.protocol === 'http-proxy');模板中把 domain/path/target/hostHeader/targetScheme/tlsServerName 这些反代专属项包进v-if="!isProxyRoute",proxy 专属项包进v-if="isProxyRoute"。 - proxy 表单项(顺序即渲染顺序):
- 名称
form.proxyName(只填<name>部分),下方proxyDomainPreview实时预览tp-.;proxyDomainSuffix从任一条已有 proxy 路由的domain推导,推导不到时回退为把完整域名交给用户填写(此时预览隐藏,form.domain直接可编辑)。这一回退必须在代码注释里写明原因:前端不读取服务端配置,避免为拿 suffix 新增一个配置接口。 - 出口 Agent 选择框:复用既有
el-select+filterAgents。 - 认证模式
el-radio-group:none/basic。 - 凭据选择
el-select(v-if="form.authMode === 'basic'"),选项来自listCredentials({ type: 'proxy_basic', status: 'active', limit: 200 }),label 为name · username;旁边一个el-button link跳/credentials(router.push)用于新建。 - 源 IP ACL
el-input+field-help,附一个el-button link“放开全部” 一键填入0.0.0.0/0。 - 目标 CIDR、目标端口(都可选)。
el-switch允许内网目标(默认开)。- 并发上限
el-input-number(:min="0")。 - 备注
el-input type="textarea" :maxlength="256" show-word-limit。
- 名称
form追加字段:proxyName: ''、authMode: 'none'、credentialId: ''、sourceCIDRs: ''、targetCIDRs: ''、targetPorts: ''、allowPrivateTargets: true、maxConcurrentTunnels: 0、description: '';resetCreateForm()与openEdit()同步补齐(openEdit把数组用join(', ')还原成字符串)。- 校验函数
validateProxyForm():名称正则/^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;authMode === 'basic'时credentialId必填;CIDR 用parseCIDRList()逐条校验(正则 +prefix <= 128,IPv4<= 32),非法时ElMessage.warning(t('routes.proxyCIDRInvalid'));端口用parsePortList()校验 1..65535。validateForm()开头分流:if (isProxyRoute.value) return validateProxyForm(),且 proxy 分支不校验targetHost。 buildProxyRouteInput()返回只含 proxy 字段的对象(不含hostHeader/targetScheme/tlsServerName/config,targetHost: ''、targetPort: 0、pathPrefix: '/');submit()按isProxyRoute选择buildProxyRouteInput()或既有input。编辑态提交时用updateRoute(id, { ...buildProxyRouteInput(), status: form.status })。- 列表新增列(放在
protocol之后、target之前,全部带min-width):- 代理地址:
row.proxyUrl ?? '',配一个复制按钮(navigator.clipboard.writeText+ElMessage.success(t('routes.proxyCopied'))),仅 proxy 行渲染。 - 认证模式:
row.authMode === 'basic' ? t('routes.proxyAuthBasic') : t('routes.proxyAuthNone'),非 proxy 行显示-。 - ACL 条数:
(row.sourceCIDRs ?? []).length。 formatTarget()对 proxy 行返回t('routes.proxyTargetDynamic')(值为“按请求动态决定”),不要显示*:0。
- 代理地址:
- 使用说明抽屉:
el-drawer+el-descriptions,内容包含proxyUrl、macOS(networksetup -setsecurewebproxy)、Windows(Internet 选项 → 连接 → 局域网设置 → 使用代理服务器,勾选“对 HTTPS 使用相同代理”)、Chrome PAC(function FindProxyForURL(url, host) { return "HTTPS tp-demo.tm.example.com:443"; })、curl(curl -x https://tp-demo.tm.example.com --proxy-user demo:****** https://ifconfig.me)四段示例,以及错误码对照表(403/407/502/503/504 → 稳定错误码)。抽屉只在 proxy 行的“详情”按钮上出现,按钮加在既有actions列里。 - 活跃隧道数列不加:
tunnelmesh_proxy_entry_tunnels_active只存在于 Prometheus,管理 API 未暴露该聚合;改为在抽屉里用t('routes.proxyActiveTunnelsHint')放一句“活跃隧道数见 Grafana「HTTP Proxy Entry」行的 Proxy tunnels active 面板”,避免为 UI 新增一个只读聚合接口。Row 与面板名保持英文字面量、中文界面也不翻译,取值以 Task 15 Step 3 写进 Dashboard 的 JSON 为准,否则用户按中文 Row 名在 Grafana 里找不到对应视图。(spec §10 列了该列,此处按“不新增无来源数据的列”收紧,并在 Step 6 记录该偏差。)
(d) web/src/views/Credentials.vue
- 类型
el-select增加<el-option :label="t('credentials.types.proxyBasic')" value="proxy_basic" />。 form追加username: '';proxy_basic时渲染 username 输入框(v-if="form.type === 'proxy_basic'"),并把公钥/私钥/口令这些 SSH 专属项包进v-if="form.type !== 'proxy_basic'"。createCredential/updateCredential的入参加username: form.type === 'proxy_basic' ? form.username.trim() : ''。- 提交校验:
proxy_basic时username与secretPassword必填(编辑态 password 留空表示不变,沿用既有secretKept文案)。 - 列表
publicKey列对proxy_basic显示 username(服务端已把 username 写进publicKey),并在type列显示t('credentials.types.proxyBasic')。 - 敏感输入清理:
clearSensitiveKeyInput()必须在proxy_basic分支同样被调用,保持credentials-view.spec.ts中>= 3次调用的既有断言不回归。
(e) i18n
zh-CN.ts 的 routes 对象追加(键名与 Step 1 测试逐字一致):protocolHttpProxy: 'HTTP 代理入口 (tp-)'、proxyName: '代理名称'、proxyNameHelp: '小写字母、数字与中划线,最长 32 字符;最终域名为 tp-<名称>.<后缀>'、proxyDomainPreview: '完整代理域名'、proxyAuthMode: '认证方式'、proxyAuthNone: '无认证'、proxyAuthBasic: '用户名密码'、proxyCredential: '代理凭据'、proxyCreateCredential: '新建凭据'、proxySourceCIDRs: '允许的来源 IP/网段'、proxySourceCIDRsHelp: '逗号分隔;默认拒绝所有来源,填 0.0.0.0/0 表示放开全部'、proxyAllowAll: '放开全部来源'、proxyTargetCIDRs: '目标网段限制(可选)'、proxyTargetPorts: '目标端口限制(可选)'、proxyAllowPrivateTargets: '允许访问内网目标'、proxyMaxConcurrentTunnels: '并发隧道上限'、proxyDescription: '备注'、proxyUrl: '代理地址'、proxyCopy: '复制'、proxyCopied: '代理地址已复制'、proxyUsage: '使用说明'、proxyUsageTitle: 'HTTP 代理入口使用说明'、proxyTargetDynamic: '按请求动态决定'、proxyNameInvalid: '代理名称只能包含小写字母、数字与中划线,且不超过 32 字符'、proxyCredentialRequired: '请选择代理凭据'、proxyCIDRInvalid: 'IP/网段格式无效'、proxyPortInvalid: '端口必须是 1-65535 的整数'。
上面这段之外再追加一个键(Step 1 的 i18n 测试已经断言它):proxyActiveTunnelsHint: '活跃隧道数见 Grafana「HTTP Proxy Entry」行的 Proxy tunnels active 面板'。en-US.ts 对应 proxyActiveTunnelsHint: 'Active tunnels are on the Grafana dashboard, row "HTTP Proxy Entry", panel "Proxy tunnels active"'。
credentials 对象追加:types 内加 proxyBasic: '代理账号',外层加 username: '用户名'、usernameRequired: '请输入用户名'。
en-US.ts 按同样键名补英文(protocolHttpProxy: 'HTTP proxy entry (tp-)'、proxyNameInvalid: 'Proxy name may contain lowercase letters, digits and hyphens, up to 32 characters' 等),键集合必须与 zh-CN 完全一致。
- Step 4: 运行测试确认通过
Run: cd web && npm test -- --run
Expected: PASS(含 layout-overflow.spec.ts、i18n.spec.ts、credentials-view.spec.ts 全部既有用例不回归)。
- Step 5: 构建并同步嵌入产物
Run: cd web && npm run build && cd .. && ./scripts/verify-web-embed.sh
Expected: 构建成功,internal/server/web_dist 已更新,嵌入校验脚本通过。
- Step 6: 记录与 spec 的偏差
在 docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md 第 10 节末尾追加一行说明:列表“活跃隧道数”列改为抽屉内指向 Grafana 面板(Row HTTP Proxy Entry、面板 Proxy tunnels active,与 Task 15 Step 3 的 Dashboard JSON 一致),原因是管理 API 未暴露该聚合,避免为纯展示新增只读接口;同时在第 17 节决策表补一行记录该收紧。
- Step 7: Commit(需授权)
git add web/src/api/client.ts web/src/api/credentials.ts web/src/views/Routes.vue web/src/views/Credentials.vue web/src/i18n/messages/zh-CN.ts web/src/i18n/messages/en-US.ts web/src/tests/routes.spec.ts web/src/tests/credentials-view.spec.ts web/src/tests/i18n.spec.ts internal/server/web_dist docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md
git commit -m "feat(web): manage tp proxy routes in the admin console"
Task 14: OpenResty 产物(Lua 搬运层、conf 模板、镜像、E2E 冒烟)
Files:
- Create:
deploy/openresty/tunnelmesh_proxy_entry.lua - Create:
deploy/openresty/tunnelmesh-proxy.conf.example - Create:
deploy/openresty/Dockerfile.proxy-connect - Create:
deploy/openresty/README.md - Create:
deploy/openresty/openresty_artifacts_test.go - Create:
test/e2e/proxy-entry/lib/stub.mjs - Create:
test/e2e/proxy-entry/lib/harness.mjs - Create:
test/e2e/proxy-entry/run.mjs - Create:
test/e2e/proxy-entry/README.md - Modify:
deploy/README.md(目录内容表、模板约定、发布归档说明) - Modify:
docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md(第 2 节补丁引用与构建顺序、第 17 节决策补充)
Interfaces:
- Consumes: Task 0 的 spike 结论(server 级
access_by_lua_file能收到 CONNECT、$connect_host/$connect_port可读、ngx.exit(444)不补发响应);Task 1 的config.Load(context.Background(), config.ConfigOptions{})默认值(Server.ProxyEntry.Listen、RouteHeader、ClientIPHeader、ClientPortHeader、IdleTimeout) - Produces:
- OpenResty -> Server 的头契约:
X-TunnelMesh-Route(取自 SNI)、X-TunnelMesh-Client-IP、X-TunnelMesh-Client-Port;三者只由 OpenResty 写入,客户端同名头一律丢弃 - Lua 渲染标记(行尾注释,部署与 E2E 按标记替换值,不改结构):
-- __TM_INTERNAL_HOST__、-- __TM_INTERNAL_PORT__、-- __TM_READ_TIMEOUT_MS__ - conf 占位符:
__LISTEN__、__SERVER_NAME_REGEX__、__SSL_CERT__、__SSL_CERT_KEY__、__LUA_FILE__、__INTERNAL_UPSTREAM__、__EDGE_ALLOW__ deploy/openresty/openresty_artifacts_test.go:把上述默认值与 Go 配置默认值绑成一个可执行契约test/e2e/proxy-entry/:只验证 OpenResty 层的冒烟脚本,TM_PROXY_E2E_NGINX=1且 docker 可用时才运行
- OpenResty -> Server 的头契约:
外部事实(已核实上游 README v0.0.7 与补丁源码,写产物前必须知道)
| 事实 | 证据 | 影响 |
|---|---|---|
$connect_host/$connect_port 由补丁注册进 nginx core 变量表 |
patch/proxy_connect_rewrite_102101.patch 的 src/http/ngx_http_variables.c 段在 ngx_http_core_variables[] 头部新增两项 ngx_http_variable_request |
Lua 直接读 ngx.var.connect_host,不自己解析请求行 |
| 补丁同时删掉了 nginx 对 CONNECT 的 405 拒绝 | 同补丁 src/http/ngx_http_request.c 段删除 client sent CONNECT method -> NGX_HTTP_NOT_ALLOWED 分支 |
只装模块不打补丁,CONNECT 一律 405 |
NGX_HTTP_PROXY_CONNECT 宏由模块 config 定义 |
模块 config 末行 have=NGX_HTTP_PROXY_CONNECT . auto/have |
补丁与模块必须同时存在,--add-module 不可省 |
| CONNECT 跳过 location 匹配 | 同补丁 src/http/ngx_http_core_module.c 段:ngx_http_update_location_config(r); r->phase_handler++; return NGX_AGAIN; |
access_by_lua_file 必须写在 server 级;location 级 content_by_lua_block 永远收不到 CONNECT |
模块不受 proxy_pass/upstream 影响,无法链式转发 |
模块 README:“Any location {} block, upstream {} block and any other standard backend/upstream directives, such as proxy_pass, do not impact the functionality of this module.” |
本设计不启用 proxy_connect; 指令,CONNECT 完全由 Lua 搬到内部入口 |
| HTTP/2 下不支持 CONNECT | 模块 README “Known Issues”:In HTTP/2, the CONNECT method is not supported. |
conf 模板禁止 http2,ALPN 只协商 http/1.1 |
| OpenResty 构建顺序是先 configure 再 patch | 模块 README “Build OpenResty”:./configure --add-module=... -> patch -d build/nginx-1.19.3/ -p 1 < ... -> make |
configure 才会解出 build/nginx-<ver>/,顺序反了补丁无处可打 |
| 版本与补丁必须成对 | 模块 README Compatibility:OpenResty 1.25.3(version 1.25.3.1)与 nginx 1.25.0~1.26.x 都用 proxy_connect_rewrite_102101.patch;模块最新 tag 为 v0.0.7(2024-08-18) |
Dockerfile 默认 pin 这一组合,换版本必须同时换补丁名 |
- Step 1: 写失败的产物一致性测试
deploy/openresty/openresty_artifacts_test.go(与 deploy/grafana/dashboard_schema_test.go 同一套路:目录里只有测试文件,go test ./deploy/... 执行;缩进用 tab,与仓库其它 Go 文件一致):
package openresty_test
import (
"context"
"net"
"os"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/tunnelmesh/tunnelmesh/internal/config"
)
// http2Directive matches the nginx directive only, never the prose that forbids
// it in the template header comment.
var http2Directive = regexp.MustCompile(`(?m)^\s*http2\s+on;`)
// readArtifact loads a shipped OpenResty artifact. Tests run with the package
// directory as working directory, so names are relative.
func readArtifact(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return string(b)
}
// TestProxyEntryArtifactsMatchServerContract pins the values that break a
// deployment silently when only one side changes: the trusted header names, the
// internal entry address and the Lua read timeout. Lua and nginx cannot be unit
// tested from Go, but the constants they must agree with can.
func TestProxyEntryArtifactsMatchServerContract(t *testing.T) {
defaults, err := config.Load(context.Background(), config.ConfigOptions{})
if err != nil {
t.Fatalf("load defaults: %v", err)
}
entry := defaults.Server.ProxyEntry
host, port, err := net.SplitHostPort(entry.Listen)
if err != nil {
t.Fatalf("default listen %q: %v", entry.Listen, err)
}
lua := readArtifact(t, "tunnelmesh_proxy_entry.lua")
conf := readArtifact(t, "tunnelmesh-proxy.conf.example")
for _, header := range []string{entry.RouteHeader, entry.ClientIPHeader, entry.ClientPortHeader} {
if !strings.Contains(lua, header) {
t.Errorf("lua does not inject trusted header %q", header)
}
if !strings.Contains(conf, "proxy_set_header "+header+" ") {
t.Errorf("conf example does not forward trusted header %q", header)
}
}
wantReadTimeout := int64((entry.IdleTimeout + 30*time.Second) / time.Millisecond)
for _, marker := range []string{
`host = "` + host + `", -- __TM_INTERNAL_HOST__`,
`port = ` + port + `, -- __TM_INTERNAL_PORT__`,
`read_timeout_ms = ` + strconv.FormatInt(wantReadTimeout, 10) + `, -- __TM_READ_TIMEOUT_MS__`,
} {
if !strings.Contains(lua, marker) {
t.Errorf("lua CONFIG drifted from the server defaults; want marker %q", marker)
}
}
if http2Directive.MatchString(conf) {
t.Error("the tp-* server block must not enable http2: ngx.req.socket(true) is unavailable on HTTP/2 downstreams and proxy_connect does not support CONNECT over HTTP/2")
}
if !strings.Contains(conf, "listen __LISTEN__ ssl;") {
t.Error("conf example must keep the listen placeholder together with ssl")
}
// Proxy-Authorization is hop-by-hop: nginx drops it on the way upstream
// unless the config re-adds it explicitly.
if !strings.Contains(conf, "proxy_set_header Proxy-Authorization $http_proxy_authorization;") {
t.Error("conf example must forward Proxy-Authorization explicitly, otherwise every non-CONNECT proxy request fails with 407")
}
if !strings.Contains(conf, "access_by_lua_file __LUA_FILE__;") {
t.Error("conf example must run the Lua mover from the server-level access phase")
}
if !strings.Contains(conf, "lua_check_client_abort on;") {
t.Error("conf example must enable lua_check_client_abort so an aborted client releases the tunnel")
}
for _, placeholder := range []string{"__LISTEN__", "__SERVER_NAME_REGEX__", "__SSL_CERT__", "__SSL_CERT_KEY__", "__LUA_FILE__", "__INTERNAL_UPSTREAM__", "__EDGE_ALLOW__"} {
if !strings.Contains(conf, placeholder) {
t.Errorf("conf example lost placeholder %q", placeholder)
}
}
if !strings.Contains(lua, "settimeouts(") {
t.Error("lua must set cosocket timeouts explicitly; the 60s default kills long tunnels")
}
if !strings.Contains(lua, "ngx.exit(444)") {
t.Error("lua must finish tunnels with ngx.exit(444) so nginx closes the connection without appending its own response")
}
if !strings.Contains(lua, "ngx.on_abort") {
t.Error("lua must register ngx.on_abort so a client disconnect closes the upstream socket immediately")
}
// The mover must stay policy-free: any of these means logic leaked out of Go.
for _, forbidden := range []string{"proxy_basic", "ParseCIDR", "sha256", "ngx.shared", "credentials"} {
if strings.Contains(lua, forbidden) {
t.Errorf("lua must not carry policy logic; found %q", forbidden)
}
}
}
// TestProxyConnectDockerfilePinsCompatibleVersions guards the pairing rule from
// the upstream module README: an OpenResty release only builds with the patch
// listed for its nginx core, and that patch only takes effect when the module
// config defines NGX_HTTP_PROXY_CONNECT. Bumping one without the other yields a
// binary that answers 405 to every CONNECT.
func TestProxyConnectDockerfilePinsCompatibleVersions(t *testing.T) {
dockerfile := readArtifact(t, "Dockerfile.proxy-connect")
for _, want := range []string{
"OPENRESTY_VERSION=1.25.3.1",
"PROXY_CONNECT_VERSION=v0.0.7",
"PROXY_CONNECT_PATCH=proxy_connect_rewrite_102101.patch",
"--add-module=/build/ngx_http_proxy_connect_module",
} {
if !strings.Contains(dockerfile, want) {
t.Errorf("Dockerfile.proxy-connect must pin %q", want)
}
}
configureIdx := strings.Index(dockerfile, "./configure")
patchIdx := strings.Index(dockerfile, "patch -d")
makeIdx := strings.Index(dockerfile, "make -j")
if configureIdx < 0 || patchIdx < 0 || makeIdx < 0 {
t.Fatalf("Dockerfile must contain ./configure, patch -d and make -j")
}
if !(configureIdx < patchIdx && patchIdx < makeIdx) {
t.Error("OpenResty build order must be ./configure -> patch -> make; configure is what unpacks build/nginx-<ver>/")
}
if !strings.Contains(dockerfile, "USER openresty:openresty") {
t.Error("runtime stage must not run as root")
}
}
- Step 2: 运行测试确认失败
Run: go test ./deploy/openresty/ -count=1
Expected: FAIL,read tunnelmesh_proxy_entry.lua: open tunnelmesh_proxy_entry.lua: no such file or directory。
- Step 3: 写 Lua 搬运层
deploy/openresty/tunnelmesh_proxy_entry.lua(缩进 4 空格;文件里不得出现任何 ACL、密码、路由查询逻辑):
-- TunnelMesh tp-* HTTP 代理入口的 CONNECT 搬运层。
--
-- 本文件只搬字节,不做任何策略判断。路由身份、源 IP ACL、Basic 认证、目标
-- 校验、并发限额、审计与指标全部由 tunnelmesh-server 的内部入口
-- (server.proxy_entry.listen,默认 127.0.0.1:8089)执行。把策略写进 Lua 会形成
-- 第二份权威,并与 Go 侧校验静默漂移。
--
-- 为什么必须是 server 级 access_by_lua_file:proxy_connect 补丁在
-- ngx_http_core_find_config_phase 里对 CONNECT 直接执行
-- ngx_http_update_location_config(r); r->phase_handler++; 跳过 location 匹配,
-- 所以 location 级的 content_by_lua_block 永远收不到 CONNECT。
--
-- CONFIG 表的默认值必须与 Server 配置默认值一致,由
-- deploy/openresty/openresty_artifacts_test.go 守护。部署时若内部入口地址或
-- idle_timeout 与默认值不同,只改这张表;行尾标记供部署脚本与 E2E 定位替换,
-- 不要删除标记本身。
local CONFIG = {
host = "127.0.0.1", -- __TM_INTERNAL_HOST__
port = 8089, -- __TM_INTERNAL_PORT__
connect_timeout_ms = 10000,
send_timeout_ms = 30000,
-- Server 的 idle_timeout + 30s:让 Server 先判定空闲并关闭,Lua 只兜底。
read_timeout_ms = 330000, -- __TM_READ_TIMEOUT_MS__
route_header = "X-TunnelMesh-Route",
client_ip_header = "X-TunnelMesh-Client-IP",
client_port_header = "X-TunnelMesh-Client-Port",
chunk_size = 65536,
max_error_body = 8192,
}
-- 非 CONNECT 请求直接放行,由 server 块的 location / 走 proxy_pass。
if ngx.req.get_method() ~= "CONNECT" then
return
end
local started = ngx.now()
local host = ngx.var.connect_host
local port = ngx.var.connect_port
local route = ngx.var.ssl_server_name
local client_ip = ngx.var.remote_addr
local client_port = ngx.var.remote_port
if not host or host == "" or not port or port == "" then
-- 变量为空只有一种可能:内核没有打 proxy_connect 补丁(未打补丁时 nginx 在
-- 解析阶段就对 CONNECT 回 405,根本走不到这里)。
ngx.log(ngx.ERR, "tunnelmesh: CONNECT target missing; nginx lacks the proxy_connect patch")
return ngx.exit(403)
end
if not route or route == "" then
-- 路由身份只来自 SNI。没有 SNI 时不回退到 Host 头,否则客户端可以用一个
-- Host 头冒充另一条路由。
ngx.log(ngx.WARN, "tunnelmesh: CONNECT without SNI target=", host, ":", port)
return ngx.exit(403)
end
local upstream = ngx.socket.tcp()
upstream:settimeouts(CONFIG.connect_timeout_ms, CONFIG.send_timeout_ms, CONFIG.read_timeout_ms)
-- 客户端断开时立刻释放上游 socket 与两条 pump 线程。lua_check_client_abort 必须
-- 为 on,否则要等到读超时(默认 330s)才回收,半开隧道会堆满 worker_connections。
local abort_ok, abort_err = pcall(ngx.on_abort, function()
ngx.log(ngx.INFO, "tunnelmesh: client aborted route=", route, " target=", host, ":", port,
" client=", client_ip, ":", client_port)
pcall(function() upstream:close() end)
end)
if not abort_ok then
ngx.log(ngx.WARN, "tunnelmesh: cannot register abort handler: ", tostring(abort_err))
end
local ok, err = upstream:connect(CONFIG.host, CONFIG.port)
if not ok then
ngx.log(ngx.ERR, "tunnelmesh: dial internal entry failed: ", err)
return ngx.exit(502)
end
-- 只写白名单头。绝不整体透传客户端请求头:客户端可以自带 X-TunnelMesh-Route
-- 冒充别的路由,也可以塞任意 hop-by-hop 头干扰 Server 解析。
local lines = {
"CONNECT " .. host .. ":" .. port .. " HTTP/1.1",
"Host: " .. host .. ":" .. port,
CONFIG.route_header .. ": " .. route,
CONFIG.client_ip_header .. ": " .. client_ip,
CONFIG.client_port_header .. ": " .. client_port,
}
local proxy_auth = ngx.var.http_proxy_authorization
if proxy_auth and proxy_auth ~= "" then
-- 原样透传,不解码不校验;认证结果由 Server 决定。日志里永远不打印它。
table.insert(lines, 3, "Proxy-Authorization: " .. proxy_auth)
end
ok, err = upstream:send(table.concat(lines, "\r\n") .. "\r\n\r\n")
if not ok then
ngx.log(ngx.ERR, "tunnelmesh: forward CONNECT failed: ", err)
upstream:close()
return ngx.exit(502)
end
local status_line
status_line, err = upstream:receive()
if not status_line then
ngx.log(ngx.ERR, "tunnelmesh: read internal entry status failed: ", err)
upstream:close()
return ngx.exit(502)
end
local status = tonumber(status_line:match("^HTTP/1%.%d (%d%d%d)"))
if not status then
ngx.log(ngx.ERR, "tunnelmesh: malformed internal entry status line")
upstream:close()
return ngx.exit(502)
end
-- 收集上游响应头。成功分支只需要状态码;失败分支要原样交还客户端,让 curl 与
-- 浏览器看到 Server 给的稳定错误码、Proxy-Authenticate 与 Retry-After。
local headers, content_length = {}, 0
while true do
local line
line, err = upstream:receive()
if not line or line == "" then
break
end
headers[#headers + 1] = line
local name, value = line:match("^([^:]+):%s*(.*)$")
if name and name:lower() == "content-length" then
content_length = tonumber(value) or 0
end
end
local downstream, derr = ngx.req.socket(true)
if not downstream then
ngx.log(ngx.ERR, "tunnelmesh: cannot take raw downstream socket: ", derr)
upstream:close()
return ngx.exit(502)
end
downstream:settimeouts(CONFIG.connect_timeout_ms, CONFIG.send_timeout_ms, CONFIG.read_timeout_ms)
if status ~= 200 then
local out = { status_line }
for i = 1, #headers do
out[#out + 1] = headers[i]
end
out[#out + 1] = ""
out[#out + 1] = ""
if content_length > 0 then
local body
body, err = upstream:receive(math.min(content_length, CONFIG.max_error_body))
if body then
out[#out + 1] = body
end
end
local _, werr = downstream:send(table.concat(out, "\r\n"))
if werr then
ngx.log(ngx.WARN, "tunnelmesh: relay rejection to client failed: ", werr)
end
ngx.log(ngx.WARN, "tunnelmesh: internal entry rejected CONNECT route=", route,
" target=", host, ":", port, " status=", status, " client=", client_ip)
upstream:close()
-- 444:nginx 立即关闭连接且不再补发响应,避免在已写出的原始响应之后又追加
-- 一个 nginx 错误页。
return ngx.exit(444)
end
local _, serr = downstream:send("HTTP/1.1 200 Connection Established\r\n\r\n")
if serr then
ngx.log(ngx.ERR, "tunnelmesh: announce tunnel to client failed: ", serr)
upstream:close()
return ngx.exit(444)
end
local function pump(from, to, label, stat)
while true do
local data, rerr, partial = from:receiveany(CONFIG.chunk_size)
if data then
stat.bytes = stat.bytes + #data
local _, werr = to:send(data)
if werr then
stat.reason = "send:" .. werr
return
end
else
-- 超时或关闭时 partial 里可能还有数据,先冲刷再结束。读超时已经比
-- Server 的 idle_timeout 长 30s,走到这里说明对端不再发数据。
if partial and #partial > 0 then
stat.bytes = stat.bytes + #partial
local _, werr = to:send(partial)
if werr then
stat.reason = "send:" .. werr
return
end
end
stat.reason = label .. ":" .. tostring(rerr or "eof")
return
end
end
end
local to_upstream = { bytes = 0 } -- 客户端 -> 内部入口 -> agent -> 目标
local to_client = { bytes = 0 } -- 目标 -> agent -> 内部入口 -> 客户端
local push = ngx.thread.spawn(pump, downstream, upstream, "downstream", to_upstream)
local pull = ngx.thread.spawn(pump, upstream, downstream, "upstream", to_client)
-- 任一方向结束就收尾。ngx.thread.kill 让另一条线程的 cosocket 操作立刻失败,
-- 否则半开隧道会一直占着 worker connection 直到读超时。
local thread_ok, thread_err = ngx.thread.wait(push, pull)
pcall(ngx.thread.kill, push)
pcall(ngx.thread.kill, pull)
upstream:close()
-- 只输出 route/target/client/字节数/时长/结束原因。Proxy-Authorization 是凭据,
-- 任何情况下都不得进入 error.log。
ngx.log(ngx.INFO, "tunnelmesh: tunnel closed route=", route, " target=", host, ":", port,
" client=", client_ip, ":", client_port,
" upstream_bytes=", to_upstream.bytes, " downstream_bytes=", to_client.bytes,
" duration=", string.format("%.3f", ngx.now() - started),
" reason=", thread_ok and tostring(to_upstream.reason or to_client.reason)
or ("thread:" .. tostring(thread_err)))
return ngx.exit(444)
实现约束(复审时逐条对照):
- 每一条
return路径都必须先upstream:close()(ngx.exit之前),或依赖ngx.on_abort;泄漏的上游连接表现为 Server 侧隧道数持续上涨。 - 不得调用
ngx.print/ngx.say/ngx.header[...]:拿到 raw downstream socket 之后响应已经由本文件手写,混用会产生第二个响应头。 pump里对send的错误必须立即结束循环,否则对端已关闭时会忙轮询打满 CPU。-
日志级别:正常结束用
ngx.INFO(部署文档要求 error_log 至少 info 才能看到隧道统计),拒绝与错误用ngx.WARN/ngx.ERR。 - Step 4: 写 conf 模板
deploy/openresty/tunnelmesh-proxy.conf.example(缩进 4 空格,与 docs/deployment/nginx.md 的既有示例一致):
# TunnelMesh tp-* HTTP 代理入口 server 块模板。
#
# 这是模板而不是可以直接 include 的配置:占位符必须由部署者渲染,含义、生产渲染
# 示例与验证步骤见 docs/deployment/openresty-proxy-entry.md。渲染后放进 http{}
# 内,与现有 admin / 托管路由 server 块并列;两块共用 443,由 SNI 选择。
#
# 占位符:
# __LISTEN__ 监听地址。生产写 443;本地验证写 127.0.0.1:18443。
# __SERVER_NAME_REGEX__ tp-* 主机名正则,生产渲染示例:
# ~^tp-[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?\.tm\.example\.com$
# __SSL_CERT__ 证书路径,必须覆盖 *.<domain_suffix>
# __SSL_CERT_KEY__ 私钥路径
# __LUA_FILE__ tunnelmesh_proxy_entry.lua 的绝对路径
# __INTERNAL_UPSTREAM__ 必须等于 server.proxy_entry.listen,默认 127.0.0.1:8089
# __EDGE_ALLOW__ 粗粒度来源白名单,生产渲染示例:
# allow 10.0.0.0/8; allow 11.0.0.0/8; deny all;
#
# 两条硬约束(由 deploy/openresty/openresty_artifacts_test.go 守护):
# 1. 本 server 块禁止 h2。ngx.req.socket(true) 在 HTTP/2 下游不可用,
# proxy_connect 模块的 Known Issues 也明确不支持 HTTP/2 的 CONNECT。
# 现有 admin server 块用 per-server 的 `http2 on;`,两块互不影响
# (要求 OpenResty 基于 nginx >= 1.25.1,Task 0 已验证)。
# 2. Proxy-Authorization 是 hop-by-hop 头,nginx 默认不转发给上游;
# location / 里少这一行,非 CONNECT 的 HTTP 代理请求会全部 407。
#
# worker_shutdown_timeout 300s; 属于 main 上下文,写在 nginx.conf 而不是这里:
# reload 时给在途隧道留排空时间,否则发布瞬间所有代理连接被立刻掐断。
upstream tunnelmesh_proxy_entry {
server __INTERNAL_UPSTREAM__;
keepalive 32;
}
server {
listen __LISTEN__ ssl;
server_name __SERVER_NAME_REGEX__;
ssl_certificate __SSL_CERT__;
ssl_certificate_key __SSL_CERT_KEY__;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:TLS:10m;
# 粗粒度前置,只挡明显的公网扫描;按路由的细粒度 ACL 在 Server 侧执行。
__EDGE_ALLOW__
# 客户端断开时立刻结束 Lua 请求,避免半开隧道占用 worker connection。
lua_check_client_abort on;
access_by_lua_file __LUA_FILE__;
# 非 CONNECT(绝对形式)请求。nginx 会把绝对形式改写成 origin-form 再交给
# location,所以 Server 侧从 Host 头恢复目标(见 splitProxyTarget)。
location / {
proxy_pass http://tunnelmesh_proxy_entry;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header Proxy-Authorization $http_proxy_authorization;
proxy_set_header X-TunnelMesh-Route $ssl_server_name;
proxy_set_header X-TunnelMesh-Client-IP $remote_addr;
proxy_set_header X-TunnelMesh-Client-Port $remote_port;
proxy_request_buffering off;
proxy_buffering off;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
- Step 5: 写 Dockerfile
deploy/openresty/Dockerfile.proxy-connect(风格对齐仓库根 Dockerfile:# syntax、ARG pin 版本、多阶段、非 root):
# syntax=docker/dockerfile:1.7
#
# OpenResty + ngx_http_proxy_connect_module 补丁内核,供 tp-* HTTP 代理入口使用。
#
# 为什么必须自己构建:没有补丁时 nginx 在 ngx_http_process_request_header 里直接
# 对 CONNECT 回 405,$connect_host / $connect_port 这两个变量也由补丁注册进
# nginx core variables。模块与补丁来自同一个 tag,缺一都会让 CONNECT 失效。
#
# 版本必须成对选择:模块 README 的 Compatibility 表列出了 OpenResty 版本与补丁
# 文件的对应关系(1.25.3.1 -> proxy_connect_rewrite_102101.patch)。换版本前先读
# https://github.com/chobits/ngx_http_proxy_connect_module#select-patch ,
# 并同步 deploy/openresty/openresty_artifacts_test.go 里的 pin 断言。
ARG ALPINE_VERSION=3.20
ARG OPENRESTY_VERSION=1.25.3.1
ARG PROXY_CONNECT_VERSION=v0.0.7
ARG PROXY_CONNECT_PATCH=proxy_connect_rewrite_102101.patch
FROM alpine:${ALPINE_VERSION} AS build
ARG OPENRESTY_VERSION
ARG PROXY_CONNECT_VERSION
ARG PROXY_CONNECT_PATCH
RUN apk add --no-cache build-base perl linux-headers openssl-dev pcre2-dev zlib-dev \
bash patch wget
WORKDIR /build
RUN wget -qO openresty.tar.gz "https://openresty.org/download/openresty-${OPENRESTY_VERSION}.tar.gz" \
&& tar -xzf openresty.tar.gz
RUN wget -qO proxy-connect.tar.gz \
"https://github.com/chobits/ngx_http_proxy_connect_module/archive/refs/tags/${PROXY_CONNECT_VERSION}.tar.gz" \
&& tar -xzf proxy-connect.tar.gz \
&& mv "ngx_http_proxy_connect_module-${PROXY_CONNECT_VERSION#v}" ngx_http_proxy_connect_module
WORKDIR /build/openresty-${OPENRESTY_VERSION}
# 替换生产 OpenResty 之前,先在目标机执行 nginx -V,把输出里的 --with-* 参数补进
# 下面这条 configure,否则现有 server 块可能因为缺模块起不来。E2E 只需要 ssl。
RUN ./configure \
--prefix=/usr/local/openresty \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module \
--with-http_gzip_static_module \
--with-pcre-jit \
--add-module=/build/ngx_http_proxy_connect_module \
-j"$(nproc)" \
&& patch -d "build/nginx-${OPENRESTY_VERSION%.*}" -p1 \
< "/build/ngx_http_proxy_connect_module/patch/${PROXY_CONNECT_PATCH}" \
&& make -j"$(nproc)" \
&& make install \
&& /usr/local/openresty/nginx/sbin/nginx -V 2>&1 | grep -q proxy_connect
FROM alpine:${ALPINE_VERSION} AS runtime
LABEL org.opencontainers.image.title="TunnelMesh OpenResty (proxy_connect)" \
org.opencontainers.image.description="OpenResty with the ngx_http_proxy_connect_module patch for the tp-* HTTP proxy entry" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.source="https://github.com/nnworld/TunnelMesh"
RUN apk add --no-cache libgcc openssl pcre2 zlib \
&& addgroup -S openresty && adduser -S -G openresty openresty
COPY --from=build /usr/local/openresty /usr/local/openresty
# 非 root 运行需要让 openresty 用户可写 logs 与各类 temp 目录;生产环境若沿用
# 宿主机既有的 OpenResty 用户,按实际情况调整 chown 目标。
RUN mkdir -p /var/run/openresty \
&& chown -R openresty:openresty /usr/local/openresty/nginx /var/run/openresty
ENV PATH=/usr/local/openresty/bin:/usr/local/openresty/nginx/sbin:${PATH}
# 443 需要 root 或 cap_net_bind_service,因此本镜像默认非 root 时只适合监听
# 1024 以上端口(E2E 用 18443)。要在容器里直接监听 443,改用 root 运行或
# setcap cap_net_bind_service=ep /usr/local/openresty/nginx/sbin/nginx。
EXPOSE 443
USER openresty:openresty
ENTRYPOINT ["/usr/local/openresty/nginx/sbin/nginx"]
CMD ["-g", "daemon off;"]
构建与验证(写进 deploy/openresty/README.md,不在计划里执行):
docker build -f deploy/openresty/Dockerfile.proxy-connect -t tunnelmesh/openresty-proxy-connect:1.25.3.1 .
docker run --rm tunnelmesh/openresty-proxy-connect:1.25.3.1 -V 2>&1 | tr ' ' '\n' | grep proxy_connect
中止判据:若 docker build 在当前环境不可用(无 docker、无外网拉取 openresty.org 与 github.com),本步骤降级为“手工步骤”,在 deploy/openresty/README.md 标注镜像未经本机构建验证,并让 Step 12 的 E2E 走 skip 分支;不得因为镜像构建失败而修改 Lua 或 conf 模板的设计。
- Step 6: 写
deploy/openresty/README.md
必须包含(与 deploy/README.md 的“产物在这里、说明在 docs”分工一致,本文件只讲产物本身,操作步骤指向 docs):
- 四个文件各自用途:
tunnelmesh_proxy_entry.lua(搬运层,无策略)、tunnelmesh-proxy.conf.example(server 块模板)、Dockerfile.proxy-connect(补丁内核镜像)、openresty_artifacts_test.go(产物与 Go 配置默认值的一致性契约)。 - 占位符与 Lua 渲染标记两张表,取值来源分别是
server.proxy_entry.*配置项与通配证书路径。 - 构建镜像、
nginx -V校验补丁、nginx -t校验渲染结果的三条命令。 - 一句话指向 OpenResty 代理入口部署 与 E2E 冒烟。
-
明确声明:本目录不进
scripts/build-release.sh的发布归档(与prometheus/、grafana/同属运维自行挂载的产物)。 - Step 7: 运行产物测试确认通过
Run: go test ./deploy/... -count=1 && go vet ./deploy/... && gofmt -l deploy/
Expected: PASS,gofmt -l 无输出;deploy/grafana 既有测试不回归。
- Step 8: 写 E2E 的内部入口替身
test/e2e/proxy-entry/lib/stub.mjs(Node 20+,无 npm 依赖,风格对齐 test/e2e/webssh/lib/):
// Scripted stand-in for the tunnelmesh-server internal proxy entry.
//
// 这不是 tunnelmesh-server:Server 的策略与转发已由 Task 9-12 的 Go 测试覆盖。
// 这里只需要一个行为可预期的回声入口,用来断言 OpenResty 搬运层做对了三件事:
// 请求头白名单、CONNECT 双向 splice、非 200 响应原样透传。
//
// 编排方式是 CONNECT 目标主机名(绝对形式则看 Host 头),不是请求头:Lua 只透传
// 白名单头,用请求头编排根本传不进来——而这正是要验证的行为。
import http from 'node:http'
const SCRIPTS = {
'denied.test': { status: 403, reason: 'Forbidden', code: 'proxy_source_denied' },
'authfail.test': {
status: 407,
reason: 'Proxy Authentication Required',
code: 'proxy_auth_failed',
extra: ['Proxy-Authenticate: Basic realm="TunnelMesh", charset="UTF-8"'],
},
'capacity.test': {
status: 503,
reason: 'Service Unavailable',
code: 'proxy_capacity_exhausted',
extra: ['Retry-After: 5'],
},
}
const scriptFor = (host) => SCRIPTS[String(host || '').split(':')[0].toLowerCase()] || null
const envelope = (status, code) => JSON.stringify({ code: status, msg: code, data: null })
export function startStub(port, address = '0.0.0.0') {
const seen = []
const server = http.createServer((req, res) => {
const record = { kind: 'http', method: req.method, url: req.url, headers: req.headers }
seen.push(record)
const script = scriptFor(req.headers.host)
const body = script ? envelope(script.status, script.code) : JSON.stringify({ url: req.url, host: req.headers.host })
const headers = { 'content-type': 'application/json' }
for (const line of script?.extra || []) {
const idx = line.indexOf(': ')
headers[line.slice(0, idx).toLowerCase()] = line.slice(idx + 2)
}
res.writeHead(script ? script.status : 200, headers)
res.end(body)
})
server.on('connect', (req, socket, head) => {
const record = { kind: 'connect', url: req.url, headers: req.headers, bytesUp: 0, bytesDown: 0, closed: false }
seen.push(record)
socket.on('close', () => { record.closed = true })
socket.on('error', () => {})
const script = scriptFor(req.url)
if (script) {
const body = envelope(script.status, script.code)
const lines = [
`HTTP/1.1 ${script.status} ${script.reason}`,
...(script.extra || []),
'content-type: application/json',
`content-length: ${Buffer.byteLength(body)}`,
'cache-control: no-store',
'',
body,
]
socket.end(lines.join('\r\n'))
return
}
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head && head.length) {
record.bytesDown += head.length
socket.write(head)
}
// 回声:客户端发来的每个字节原样送回,用来验证双向 splice 与字节完整性。
socket.on('data', (chunk) => {
record.bytesUp += chunk.length
socket.write(chunk, () => { record.bytesDown += chunk.length })
})
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(port, address, () => resolve({
port: server.address().port,
seen,
find: (predicate) => seen.find(predicate),
reset: () => { seen.length = 0 },
close: () => new Promise((done) => { server.closeAllConnections?.(); server.close(done) }),
}))
})
}
- Step 9: 写 E2E 的 OpenResty 运行时
test/e2e/proxy-entry/lib/harness.mjs:
// OpenResty runtime for the tp-* proxy entry smoke test.
//
// 渲染的对象就是 deploy/openresty/ 下的真实产物:conf 按占位符替换,Lua 按行尾
// 标记替换值。测试因此覆盖发布物本身,而不是一份手抄副本。
import { execFileSync, spawn } from 'node:child_process'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
export const repoRoot = path.resolve(here, '..', '..', '..', '..')
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const number = (value, fallback) => (Number.isFinite(Number(value)) && value !== '' && value != null ? Number(value) : fallback)
const runs = (cmd, args) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true } catch { return false } }
/** resolveConfig merges environment overrides onto safe local defaults. */
export function resolveConfig(env = process.env) {
const root = env.TM_PROXY_E2E_REPO || repoRoot
const workDir = env.TM_PROXY_E2E_DIR || path.join(os.tmpdir(), 'tunnelmesh-proxy-entry-e2e')
const domainSuffix = env.TM_PROXY_E2E_DOMAIN || 'proxy.test'
const routeName = env.TM_PROXY_E2E_ROUTE || 'e2e'
return {
enabled: env.TM_PROXY_E2E_NGINX === '1',
repoRoot: root,
workDir,
listenPort: number(env.TM_PROXY_E2E_PORT, 18443),
stubPort: number(env.TM_PROXY_E2E_STUB_PORT, 18089),
image: env.TM_PROXY_E2E_IMAGE || 'tunnelmesh/openresty-proxy-connect:1.25.3.1',
skipBuild: env.TM_PROXY_E2E_SKIP_BUILD === '1',
containerName: env.TM_PROXY_E2E_CONTAINER || `tunnelmesh-proxy-entry-e2e-${process.pid}`,
domainSuffix,
routeName,
proxyHost: `tp-${routeName}.${domainSuffix}`,
artifacts: {
lua: path.join(root, 'deploy/openresty/tunnelmesh_proxy_entry.lua'),
conf: path.join(root, 'deploy/openresty/tunnelmesh-proxy.conf.example'),
dockerfile: path.join(root, 'deploy/openresty/Dockerfile.proxy-connect'),
},
}
}
/** missingPrerequisites lists everything that would make the run impossible. */
export function missingPrerequisites(config) {
const missing = []
if (!runs('docker', ['info'])) missing.push('docker CLI with a reachable daemon')
if (!runs('openssl', ['version'])) missing.push('openssl')
for (const [name, file] of Object.entries(config.artifacts)) {
if (!fs.existsSync(file)) missing.push(`${name} artifact ${file}`)
}
return missing
}
export function buildImage(config, log) {
if (config.skipBuild) {
log(`TM_PROXY_E2E_SKIP_BUILD=1; using image ${config.image} as-is`)
return
}
log(`building ${config.image} (the first build compiles OpenResty and takes minutes)`)
execFileSync('docker', ['build', '-f', config.artifacts.dockerfile, '-t', config.image, config.repoRoot], { stdio: 'inherit' })
}
export function renderArtifacts(config, log) {
fs.rmSync(config.workDir, { recursive: true, force: true })
fs.mkdirSync(config.workDir, { recursive: true })
execFileSync('openssl', [
'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
'-keyout', path.join(config.workDir, 'tls.key'),
'-out', path.join(config.workDir, 'tls.crt'),
'-subj', `/CN=${config.proxyHost}`,
'-addext', `subjectAltName=DNS:${config.proxyHost},DNS:*.${config.domainSuffix}`,
], { stdio: 'ignore' })
// 容器里的 OpenResty 通过 host.docker.internal 回到宿主机上的 stub。
const lua = fs.readFileSync(config.artifacts.lua, 'utf8')
.replace(/host = "[^"]*", -- __TM_INTERNAL_HOST__/, 'host = "host.docker.internal", -- __TM_INTERNAL_HOST__')
.replace(/port = \d+, -- __TM_INTERNAL_PORT__/, `port = ${config.stubPort}, -- __TM_INTERNAL_PORT__`)
fs.writeFileSync(path.join(config.workDir, 'tunnelmesh_proxy_entry.lua'), lua)
const escapedSuffix = config.domainSuffix.replaceAll('.', '\\.')
const conf = fs.readFileSync(config.artifacts.conf, 'utf8')
.replaceAll('__LISTEN__', String(config.listenPort))
.replaceAll('__SERVER_NAME_REGEX__', `~^tp-[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?\\.${escapedSuffix}$`)
.replaceAll('__SSL_CERT__', '/etc/tunnelmesh/tls.crt')
.replaceAll('__SSL_CERT_KEY__', '/etc/tunnelmesh/tls.key')
.replaceAll('__LUA_FILE__', '/etc/tunnelmesh/tunnelmesh_proxy_entry.lua')
.replaceAll('__INTERNAL_UPSTREAM__', `host.docker.internal:${config.stubPort}`)
.replaceAll('__EDGE_ALLOW__', 'allow all;')
fs.writeFileSync(path.join(config.workDir, 'tunnelmesh-proxy.conf'), conf)
// 这份 wrapper 是测试夹具而不是仓库产物:生产环境把渲染后的 server 块并进既有
// nginx.conf。日志走 stdout/stderr 并由 harness 落成 container.log,挂载目录
// 因此可以保持只读。
fs.writeFileSync(path.join(config.workDir, 'nginx.conf'), [
'worker_processes 1;',
'error_log /dev/stderr info;',
'pid /tmp/nginx.pid;',
'events { worker_connections 256; }',
'http {',
' access_log /dev/stdout;',
' client_body_temp_path /tmp/nginx-client-body;',
' proxy_temp_path /tmp/nginx-proxy;',
' fastcgi_temp_path /tmp/nginx-fastcgi;',
' uwsgi_temp_path /tmp/nginx-uwsgi;',
' scgi_temp_path /tmp/nginx-scgi;',
' include /etc/tunnelmesh/tunnelmesh-proxy.conf;',
'}',
'',
].join('\n'))
log(`rendered artifacts into ${config.workDir}`)
}
export function startNginx(config, log) {
const logFile = path.join(config.workDir, 'container.log')
const out = fs.openSync(logFile, 'a')
const child = spawn('docker', [
'run', '--rm', '--name', config.containerName,
'-p', `127.0.0.1:${config.listenPort}:${config.listenPort}`,
'-v', `${config.workDir}:/etc/tunnelmesh:ro`,
'--add-host', 'host.docker.internal:host-gateway',
config.image,
'-c', '/etc/tunnelmesh/nginx.conf', '-g', 'daemon off;',
], { stdio: ['ignore', out, out] })
child.on('exit', (code) => log(`nginx container exited with code ${code}`))
return {
child,
logFile,
tail: (lines = 80) => {
try { return fs.readFileSync(logFile, 'utf8').split('\n').slice(-lines).join('\n') } catch { return '' }
},
stop: async () => {
try { execFileSync('docker', ['rm', '-f', config.containerName], { stdio: 'ignore' }) } catch { /* already gone */ }
await sleep(200)
try { fs.closeSync(out) } catch { /* already closed */ }
},
}
}
export async function waitListening(port, timeoutMs = 60000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const ok = await new Promise((resolve) => {
const probe = net.connect(port, '127.0.0.1')
probe.once('connect', () => { probe.destroy(); resolve(true) })
probe.once('error', () => { probe.destroy(); resolve(false) })
})
if (ok) return true
await sleep(300)
}
return false
}
- Step 10: 写 E2E 场景脚本
test/e2e/proxy-entry/run.mjs:
// tp-* HTTP proxy entry: OpenResty layer smoke test.
//
// 只验证 OpenResty 搬运层(deploy/openresty/ 的产物)。Server 的路由解析、ACL、
// 认证、目标校验、限额与转发由 internal/proxyentry 与 internal/server 的 Go 测试
// 覆盖,这里不重复。
//
// 用 Node 的 tls 模块而不是 curl:路由身份来自 SNI,Node 可以在连 127.0.0.1 的
// 同时指定 servername,不必改 /etc/hosts,也不依赖各版本 curl 对 https 代理的
// --resolve 行为。
//
// 运行条件与用法见同目录 README.md。
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import tls from 'node:tls'
import { buildImage, missingPrerequisites, renderArtifacts, resolveConfig, startNginx, waitListening } from './lib/harness.mjs'
import { startStub } from './lib/stub.mjs'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const log = (...args) => console.log('[e2e]', ...args)
const config = resolveConfig()
const results = []
const check = (name, ok, detail) => {
results.push({ name, ok: !!ok, detail })
log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`)
}
const credential = `Basic ${Buffer.from('e2e:s3cret').toString('base64')}`
function connectTLS() {
return new Promise((resolve, reject) => {
const socket = tls.connect({
host: '127.0.0.1',
port: config.listenPort,
servername: config.proxyHost,
rejectUnauthorized: false,
ALPNProtocols: ['http/1.1'],
})
socket.once('secureConnect', () => resolve(socket))
socket.once('error', reject)
})
}
// readHead consumes everything up to the blank line that ends the proxy
// response head and hands back whatever body bytes rode along in the same read.
function readHead(socket, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const chunks = []
const timer = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for the proxy response head')) }, timeoutMs)
const onData = (chunk) => {
chunks.push(chunk)
const buf = Buffer.concat(chunks)
const idx = buf.indexOf('\r\n\r\n')
if (idx < 0) return
cleanup()
resolve({ head: buf.subarray(0, idx).toString('utf8'), rest: buf.subarray(idx + 4) })
}
const onError = (err) => { cleanup(); reject(err) }
const cleanup = () => { clearTimeout(timer); socket.off('data', onData); socket.off('error', onError) }
socket.on('data', onData)
socket.on('error', onError)
})
}
function readBytes(socket, count, timeoutMs = 20000) {
return new Promise((resolve, reject) => {
const chunks = []
let total = 0
const timer = setTimeout(() => { cleanup(); reject(new Error(`timeout after ${total}/${count} bytes`)) }, timeoutMs)
const onData = (chunk) => {
chunks.push(chunk)
total += chunk.length
if (total >= count) { cleanup(); resolve(Buffer.concat(chunks).subarray(0, count)) }
}
const onError = (err) => { cleanup(); reject(err) }
const cleanup = () => { clearTimeout(timer); socket.off('data', onData); socket.off('error', onError) }
socket.on('data', onData)
socket.on('error', onError)
})
}
const parseHead = (head) => {
const [statusLine, ...lines] = head.split('\r\n')
const headers = {}
for (const line of lines) {
const idx = line.indexOf(':')
if (idx > 0) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim()
}
return { statusLine, status: Number(statusLine.split(' ')[1]), headers }
}
async function readBody(socket, rest, headers) {
const want = Number(headers['content-length'] || 0)
if (!want || rest.length >= want) return rest.subarray(0, want).toString('utf8')
const more = await readBytes(socket, want - rest.length)
return Buffer.concat([rest, more]).toString('utf8')
}
async function sendConnect(target, extraHeaders = []) {
const socket = await connectTLS()
socket.write([
`CONNECT ${target} HTTP/1.1`,
`Host: ${target}`,
`Proxy-Authorization: ${credential}`,
...extraHeaders,
'', '',
].join('\r\n'))
return socket
}
let stub
let nginx
let exitCode = 0
if (!config.enabled) {
results.push({ name: 'openresty proxy entry smoke', ok: true, skipped: true, detail: 'TM_PROXY_E2E_NGINX is not 1' })
log('SKIP openresty proxy entry smoke :: set TM_PROXY_E2E_NGINX=1 to run (needs docker and openssl)')
console.log('\n== summary ==')
for (const r of results) console.log(`SKIP ${r.name}`)
process.exit(0)
}
const missing = missingPrerequisites(config)
if (missing.length) {
results.push({ name: 'openresty proxy entry smoke', ok: true, skipped: true, detail: missing.join('; ') })
log(`SKIP openresty proxy entry smoke :: missing ${missing.join('; ')}`)
console.log('\n== summary ==')
for (const r of results) console.log(`SKIP ${r.name}`)
process.exit(0)
}
try {
buildImage(config, log)
renderArtifacts(config, log)
stub = await startStub(config.stubPort)
nginx = startNginx(config, log)
if (!await waitListening(config.listenPort)) {
throw new Error(`openresty did not listen on 127.0.0.1:${config.listenPort}\n${nginx.tail(60)}`)
}
log(`openresty listening on 127.0.0.1:${config.listenPort}, stub on :${config.stubPort}`)
// 1. CONNECT 搬运与请求头白名单
stub.reset()
{
const socket = await sendConnect('ok.test:443', [
'X-TunnelMesh-Route: tp-forged.example.com',
'X-TunnelMesh-Client-IP: 203.0.113.7',
'X-Evil: dropped',
])
const { head } = await readHead(socket)
const { status } = parseHead(head)
const record = stub.find((item) => item.kind === 'connect')
check('CONNECT is answered with 200 Connection Established',
status === 200 && head.startsWith('HTTP/1.1 200'), `status=${status}`)
check('route identity comes from SNI, not from a client header',
record?.headers['x-tunnelmesh-route'] === config.proxyHost, `got=${record?.headers['x-tunnelmesh-route']}`)
check('a forged client IP is replaced by the real peer',
!!record?.headers['x-tunnelmesh-client-ip'] && record.headers['x-tunnelmesh-client-ip'] !== '203.0.113.7',
`got=${record?.headers['x-tunnelmesh-client-ip']}`)
check('Proxy-Authorization is passed through verbatim',
record?.headers['proxy-authorization'] === credential,
record?.headers['proxy-authorization'] ? 'value differs' : 'header missing')
check('non-whitelisted client headers are dropped',
record?.headers['x-evil'] === undefined, `got=${record?.headers['x-evil']}`)
// 2. 双向字节完整性
const payload = crypto.randomBytes(256 * 1024)
const echoed = readBytes(socket, payload.length)
socket.write(payload)
const received = await echoed
check('the tunnel carries 256 KiB in both directions byte-for-byte',
received.equals(payload), `bytes=${received.length} upstream=${record?.bytesUp}`)
// 3. 客户端断开后隧道被回收
socket.destroy()
await sleep(1500)
check('the tunnel is released when the client goes away',
record?.closed === true, `closed=${record?.closed}`)
}
// 4. 非 200 响应原样透传(状态行、头、body)
for (const [target, wantStatus, wantHeader] of [
['denied.test:443', 403, null],
['authfail.test:443', 407, 'proxy-authenticate'],
['capacity.test:443', 503, 'retry-after'],
]) {
const socket = await sendConnect(target)
const { head, rest } = await readHead(socket)
const { status, headers } = parseHead(head)
const body = await readBody(socket, rest, headers)
const ok = status === wantStatus
&& body.includes(`"code":${wantStatus}`)
&& (!wantHeader || !!headers[wantHeader])
check(`${wantStatus} from the internal entry is relayed verbatim (${target})`, ok,
`status=${status} ${wantHeader ? `${wantHeader}=${headers[wantHeader] || '<missing>'} ` : ''}body=${body.slice(0, 120)}`)
socket.destroy()
}
// 5. 绝对形式(非 CONNECT)经 location / 到达内部入口
stub.reset()
{
const socket = await connectTLS()
socket.write([
'GET http://ok.test/probe HTTP/1.1',
'Host: ok.test',
`Proxy-Authorization: ${credential}`,
'X-TunnelMesh-Route: tp-forged.example.com',
'Connection: close',
'', '',
].join('\r\n'))
const { head } = await readHead(socket)
const { status } = parseHead(head)
const record = stub.find((item) => item.kind === 'http')
check('absolute-form requests arrive as origin-form with the trusted headers',
status === 200
&& record?.url === '/probe'
&& record?.headers['host'] === 'ok.test'
&& record?.headers['proxy-authorization'] === credential
&& record?.headers['x-tunnelmesh-route'] === config.proxyHost,
`status=${status} url=${record?.url} host=${record?.headers?.host} route=${record?.headers?.['x-tunnelmesh-route']}`)
socket.destroy()
}
// 6. 日志既要能排障,也不能泄漏凭据
{
const text = nginx.tail(400)
check('the error log records tunnel close with route and byte counters',
text.includes(`tunnelmesh: tunnel closed route=${config.proxyHost}`), 'no tunnel close line found')
check('the error log never contains the proxy credential',
!text.includes(credential.split(' ')[1]), 'base64 credential leaked into the log')
}
exitCode = results.every((r) => r.ok) ? 0 : 1
} catch (error) {
log('scenario crashed:', error?.stack || error)
if (nginx) log(nginx.tail(60))
exitCode = 1
} finally {
console.log('\n== summary ==')
for (const r of results) console.log(`${r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'} ${r.name}`)
fs.writeFileSync(path.join(config.workDir, 'results.json'), JSON.stringify({ results }, null, 2))
if (nginx) await nginx.stop()
if (stub) await stub.close()
log(`workdir ${config.workDir} (rendered artifacts, container.log and results.json kept)`)
process.exit(exitCode)
}
- Step 11: 写
test/e2e/proxy-entry/README.md
必须包含(结构对齐 test/e2e/webssh/README.md):
- 一句话定位:只覆盖 OpenResty 搬运层,不覆盖 Server 策略;不属于
go test ./...与npm test。 - 断言表:逐条列出 Step 10 的 check 名称与“为什么存在”(头白名单防路由冒充、hop-by-hop 头透传、非 200 原样回传保证稳定错误码与
Proxy-Authenticate/Retry-After不被吞、客户端断开后隧道回收防连接泄漏、日志不得出现凭据)。 - 前置条件:Node 20+、docker(含可用 daemon)、openssl;首次运行会编译 OpenResty 镜像(数分钟)。
- 运行命令与环境变量表:
TM_PROXY_E2E_NGINX、TM_PROXY_E2E_IMAGE、TM_PROXY_E2E_SKIP_BUILD、TM_PROXY_E2E_PORT、TM_PROXY_E2E_STUB_PORT、TM_PROXY_E2E_DOMAIN、TM_PROXY_E2E_ROUTE、TM_PROXY_E2E_DIR、TM_PROXY_E2E_REPO。 - skip 语义:未设置
TM_PROXY_E2E_NGINX=1或前置条件缺失时打印原因并以 0 退出,不得静默通过。 -
失败排查:先看
<workdir>/container.log(error_log 走 stderr,级别 info),再看<workdir>/results.json;渲染后的 conf 与 Lua 也在<workdir>里,可直接nginx -t复查。 - Step 12: 验证 skip 分支
Run: node test/e2e/proxy-entry/run.mjs
Expected: 输出 SKIP openresty proxy entry smoke :: set TM_PROXY_E2E_NGINX=1 to run (needs docker and openssl),退出码 0。
- Step 13: 运行完整冒烟(需要 docker)
Run: TM_PROXY_E2E_NGINX=1 node test/e2e/proxy-entry/run.mjs
Expected: 全部 check PASS,退出码 0。
若当前环境没有 docker 或无法访问 openresty.org / github.com:把本步骤标记为“未在本机执行”,在 test/e2e/proxy-entry/README.md 顶部注明镜像与冒烟尚未经本机构建验证,并在 PR 记录的 Test Evidence 里如实写明“OpenResty 层为未验证项,需在具备 docker 的环境执行一次”。不得为了让脚本通过而放宽断言。
-
Step 14: 更新
deploy/README.md -
“目录内容”表新增一行: openresty/tunnelmesh_proxy_entry.lua、openresty/tunnelmesh-proxy.conf.example、openresty/Dockerfile.proxy-connecttp-* HTTP 代理入口的 OpenResty 搬运层、server 块模板与补丁内核镜像 OpenResty 代理入口部署。 - “目录内容”表的产物一致性测试行补上
openresty/openresty_artifacts_test.go。 - “模板约定”小节新增:
openresty/tunnelmesh-proxy.conf.example占位符__LISTEN__、__SERVER_NAME_REGEX__、__SSL_CERT__、__SSL_CERT_KEY__、__LUA_FILE__、__INTERNAL_UPSTREAM__、__EDGE_ALLOW__;tunnelmesh_proxy_entry.lua的CONFIG表用行尾标记-- __TM_INTERNAL_HOST__、-- __TM_INTERNAL_PORT__、-- __TM_READ_TIMEOUT_MS__定位替换;默认值与server.proxy_entry.*的一致性由openresty_artifacts_test.go守护。 - “发布包内容”小节补一句:
openresty/与prometheus/、grafana/同属运维自行挂载的产物,不进scripts/build-release.sh归档。 -
“校验”小节补两行:
go test ./deploy/openresty -count=1与TM_PROXY_E2E_NGINX=1 node test/e2e/proxy-entry/run.mjs(需要 docker)。 - Step 15: 修正 spec 的外部引用
docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md:
- 第 2 节表格第 1 行的证据列把
patch/proxy_connect.patch改为“上游模块chobits/ngx_http_proxy_connect_modulev0.0.7 的patch/proxy_connect_rewrite_102101.patch(仓库内不存在该文件,Dockerfile 构建时拉取)”,并保留原有的ngx_http_core_find_config_phase代码引用。 - 第 2 节表格第 2 行的证据列补上补丁的准确文件名,并新增三行事实:补丁删除
ngx_http_process_request_header中对 CONNECT 的 405 拒绝;NGX_HTTP_PROXY_CONNECT宏由模块config的have=NGX_HTTP_PROXY_CONNECT . auto/have定义;OpenResty 构建顺序为./configure->patch -d build/nginx-<ver>/->make。 - 第 2 节表格末尾新增一行:模块 README “Known Issues” 明确 HTTP/2 下不支持 CONNECT,与
ngx.req.socket的 HTTP/2 限制互为双重理由。 - 第 5.1 节的示例配置改为引用模板产物,并说明它使用占位符(渲染示例见
docs/deployment/openresty-proxy-entry.md),避免同一份 nginx 配置在 spec、模板与文档里各存一份。 - 第 14 节第 11 条把“Go stub 上游”改为“Node stub 上游(
test/e2e/proxy-entry/lib/stub.mjs)”,并写明冒烟只覆盖 OpenResty 层、Server 策略由 Go 测试覆盖。 -
第 17 节决策表新增一行:
OpenResty 产物形态 | 占位符模板 + Go 侧产物一致性测试 | 直接写死生产值:E2E 只能测一份手抄副本,模板与 Go 默认值漂移无人发现。 - Step 16: Commit(需授权)
git add deploy/openresty/tunnelmesh_proxy_entry.lua deploy/openresty/tunnelmesh-proxy.conf.example deploy/openresty/Dockerfile.proxy-connect deploy/openresty/README.md deploy/openresty/openresty_artifacts_test.go deploy/README.md test/e2e/proxy-entry/run.mjs test/e2e/proxy-entry/README.md test/e2e/proxy-entry/lib/harness.mjs test/e2e/proxy-entry/lib/stub.mjs docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md
git commit -m "feat(deploy): ship openresty tp proxy entry artifacts"
Task 15: 观测面板、告警、文档与 PR 记录(收尾)
Files:
- Modify:
deploy/grafana/dashboard_schema_test.go(row 数量 5 -> 6、新 row 标题、5 个新必需指标) - Modify:
deploy/grafana/dashboards/tunnelmesh.json(追加HTTP Proxy Entryrow 与 7 个面板) - Modify:
deploy/prometheus/alert-rules.yaml(2 条代理入口告警) - Modify:
docs/operations/observability.md(Row 结构、route/reason标签基数、告警责任) - Create:
docs/deployment/openresty-proxy-entry.md - Create:
docs/user-guide/http-proxy-entry.md - Modify:
docs/deployment/nginx.md - Modify:
docs/operations/troubleshooting.md - Modify:
docs/README.md、docs/user-guide/managed-http-route.md、docs/operations/completeness-checklist.md - Modify:
README.md、README.zh-CN.md、deploy/README.md - Create:
docs/pull-requests/2026-09-13-managed-route-http-proxy-entry.md - Regenerate:
docs/pull-requests/README.md、docs/superpowers/plans/README.md、docs/superpowers/specs/README.md、docs/architecture/adr/README.md(python3 scripts/gen_doc_index.py) - Modify:
docs/development/testing.md、docs/development/README.md(Step 11:验证层级表与索引补 proxy-entry 端到端冒烟) - Modify:
docs/architecture/overview.md(Step 11:tp-* 入口拓扑链路,spec 第 16 节要求) - Modify:
docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md(Step 15(b):第 13 / 14 / 15 节偏差回写) - Modify:
docs/operations/configuration.md(server.proxy_entry配置表已由 Task 1 写入,Step 16 仅作为兜底出现在git add,通常无变化)
Interfaces:
- Consumes: Task 10 的 5 个指标与标签(
tunnelmesh_proxy_entry_requests_total{route,mode,result,error_class}、_tunnels_active{route}、_tunnel_duration_seconds{route,result}、_auth_failures_total{route,reason}、_acl_denied_total{route})与result取值success/denied/untrusted_peer;Task 1 的server.proxy_entry.*配置项;Task 12 的管理 API 字段与docs/api/openapi.yaml;Task 14 的 OpenResty 产物、占位符与部署约束 -
Produces: 无。本任务是收尾,不被其它任务依赖。
- Step 1: 先改 Grafana schema 测试(红灯)
deploy/grafana/dashboard_schema_test.go 的 TestTunnelMeshDashboardSchema 改三处:
if strings.Count(string(b), `"type": "row"`) != 6 {
t.Fatalf("dashboard must contain exactly six row panels")
}
for _, row := range []string{"Overview", "Agent", "Network", "Cluster", "Security", "HTTP Proxy Entry"} {
if !strings.Contains(string(b), `"title": "`+row+`"`) {
t.Fatalf("missing row %q", row)
}
}
并在函数末尾的必需指标切片里追加 5 项(放在既有 10 项之后):
"tunnelmesh_proxy_entry_requests_total",
"tunnelmesh_proxy_entry_tunnels_active",
"tunnelmesh_proxy_entry_tunnel_duration_seconds",
"tunnelmesh_proxy_entry_auth_failures_total",
"tunnelmesh_proxy_entry_acl_denied_total",
为什么先改测试:Dashboard 是这个仓库里唯一“改错了也不会报错”的产物,schema 测试是它的唯一门禁;先加断言才能保证新 row 不是靠肉眼检查通过的。
- Step 2: 运行测试确认失败
Run: go test ./deploy/grafana/ -count=1
Expected: FAIL,dashboard must contain exactly six row panels。
- Step 3: 追加 Dashboard row
在 deploy/grafana/dashboards/tunnelmesh.json 的 panels 数组末尾(Security row 之后)追加下面这一段。保持仓库既有写法:每个面板单独一行、datasource 固定 ${DS_PROMETHEUS}、不写 id、table 面板带 "format": "table"、row 本身不带 gridPos。
{"type": "row", "title": "HTTP Proxy Entry", "collapsed": false, "panels": [
{"type": "stat", "title": "Proxy denials", "datasource": "${DS_PROMETHEUS}", "targets": [{"expr": "sum(increase(tunnelmesh_proxy_entry_requests_total{cluster=~\"$cluster\", node_id=~\"$node_id\", result!=\"success\"}[15m]))", "refId": "A"}], "gridPos": {"h": 4, "w": 6, "x": 0, "y": 80}},
{"type": "timeseries", "title": "Proxy requests", "datasource": "${DS_PROMETHEUS}", "targets": [{"expr": "sum by (mode, result) (rate(tunnelmesh_proxy_entry_requests_total{cluster=~\"$cluster\", node_id=~\"$node_id\"}[5m]))", "refId": "A"}], "gridPos": {"h": 8, "w": 9, "x": 6, "y": 80}},
{"type": "timeseries", "title": "Proxy tunnels active", "datasource": "${DS_PROMETHEUS}", "targets": [{"expr": "sum by (route) (tunnelmesh_proxy_entry_tunnels_active{cluster=~\"$cluster\", node_id=~\"$node_id\"})", "refId": "A"}], "gridPos": {"h": 8, "w": 9, "x": 15, "y": 80}},
{"type": "timeseries", "title": "Proxy tunnel duration p95", "datasource": "${DS_PROMETHEUS}", "targets": [{"expr": "histogram_quantile(0.95, sum by (le, route, result) (rate(tunnelmesh_proxy_entry_tunnel_duration_seconds_bucket{cluster=~\"$cluster\", node_id=~\"$node_id\"}[5m])))", "refId": "A"}], "gridPos": {"h": 8, "w": 12, "x": 0, "y": 88}},
{"type": "timeseries", "title": "Proxy throughput", "datasource": "${DS_PROMETHEUS}", "targets": [{"expr": "sum by (direction) (rate(tunnelmesh_bytes_total{cluster=~\"$cluster\", node_id=~\"$node_id\", component=\"proxy_entry\"}[5m]))", "refId": "A"}], "gridPos": {"h": 8, "w": 12, "x": 12, "y": 88}},
{"type": "timeseries", "title": "Proxy auth failures and ACL denials", "datasource": "${DS_PROMETHEUS}", "targets": [{"expr": "sum by (route, reason) (rate(tunnelmesh_proxy_entry_auth_failures_total{cluster=~\"$cluster\", node_id=~\"$node_id\"}[5m]))", "refId": "A"}, {"expr": "sum by (route) (rate(tunnelmesh_proxy_entry_acl_denied_total{cluster=~\"$cluster\", node_id=~\"$node_id\"}[5m]))", "refId": "B"}], "gridPos": {"h": 8, "w": 12, "x": 0, "y": 96}},
{"type": "table", "title": "Proxy entry denials", "datasource": "${DS_PROMETHEUS}", "targets": [{"expr": "sum by (route, mode, result, error_class) (increase(tunnelmesh_proxy_entry_requests_total{cluster=~\"$cluster\", node_id=~\"$node_id\", result!=\"success\"}[15m]))", "refId": "A", "format": "table"}], "gridPos": {"h": 8, "w": 12, "x": 12, "y": 96}}
]}
注意 JSON 语法:前一个 row(Security)的结尾 ]} 之后要补逗号。
同时把顶层 description 从 Unified TunnelMesh control-plane, agent, network, cluster, and security observability. 改为 Unified TunnelMesh control-plane, agent, network, cluster, security, and HTTP proxy entry observability.,避免描述与 Row 结构不一致(当前仓库实测为 5 个 row:Overview / Agent / Network / Cluster / Security,本步骤后为 6 个)。
- Step 4: 运行测试确认通过
Run: go test ./deploy/... -count=1
Expected: PASS(含 Task 14 的 deploy/openresty 与既有 deploy/install)。
- Step 5: 追加告警规则
deploy/prometheus/alert-rules.yaml 的 tunnelmesh-alerts 组末尾追加:
- alert: TunnelMeshProxyEntryAuthFailures
expr: sum(rate(tunnelmesh_proxy_entry_auth_failures_total[10m])) > 0.2
for: 10m
labels: {severity: warning}
annotations: {summary: TunnelMesh tp proxy entry sees sustained Basic auth failures}
- alert: TunnelMeshProxyEntryDenials
expr: sum(rate(tunnelmesh_proxy_entry_requests_total{result!="success"}[10m])) > 0.5
for: 10m
labels: {severity: warning}
annotations: {summary: TunnelMesh tp proxy entry is denying proxy requests}
与既有规则不同,这两条不加 absent(...) or 前缀:server.proxy_entry.enabled 默认 false,未启用时指标根本不存在,加 absent() 会让所有未启用该功能的部署持续告警。阈值取“10 分钟平均每秒 0.2 次认证失败 / 0.5 次拒绝”,低于该量级的零星扫描不值得叫人。
Run: promtool check rules deploy/prometheus/alert-rules.yaml
Expected: SUCCESS: 9 rules found(既有 7 条 + 新增 2 条)。若本机没有 promtool,改用 python3 -c "import yaml,sys; yaml.safe_load(open('deploy/prometheus/alert-rules.yaml'))" 校验语法,并在 PR 记录里注明 promtool 未执行。
- Step 6: 更新可观测性文档
docs/operations/observability.md:
- “Grafana” 小节第一段把“按 Overview、Agent、Network、Cluster、Security 五个 Row 组织”改为六个 Row 并加上
HTTP Proxy Entry。 - 该小节列 Row 内容的列表追加一条:
HTTP Proxy Entry Row:代理请求速率与结果、活跃隧道数(按 route)、隧道时长 p95、代理入口上下行字节、认证失败与 ACL 拒绝、拒绝明细表。 - 补一句:
server.proxy_entry.enabled=false时该行所有面板为空,属正常现象。 - “标签和留存”小节:在允许的低基数标签清单里加入
route与reason,并写明基数边界——route取值是tp-<name>.<domain_suffix>完整域名,数量等于管理后台创建的代理路由数(人工创建,非请求维度);reason取值固定为认证失败原因枚举。同时重申目标地址、客户端 IP、Proxy-Authorization不得进入 label。 - “告警责任”小节补一句:代理入口的认证失败与拒绝告警归业务负责人(与路由拒绝同类),值班需要能区分“ACL 配错”和“密码爆破”,判据是
tunnelmesh_proxy_entry_acl_denied_total与_auth_failures_total的相对量级。 -
指标清单处(若有)补 5 个
tunnelmesh_proxy_entry_*指标及其标签,与 Task 10 的NewMetrics注册一致。 - Step 7: 写
docs/deployment/openresty-proxy-entry.md
必须包含(操作步骤写在这里,deploy/openresty/README.md 只讲产物,两边不重复):
- 适用前提:OpenResty(nginx >= 1.25.1 内核 + lua-nginx-module + 已打
proxy_connect_rewrite_102101.patch);通配证书覆盖*.<domain_suffix>;泛解析 DNStp-*.<domain_suffix>指向入口 IP;推荐 OpenResty 与 tunnelmesh-server 同机(内部入口只绑回环)。给出两级检查命令:先nginx -V 2>&1 | tr ' ' '\n' | grep proxy_connect确认模块编进了内核,再bash deploy/openresty/spike-connect-check.sh 18443(Task 0 产物)确认补丁真的生效。必须写清为什么两级都要:模块单独编译进去时nginx -V一样能看到--add-module,但没打补丁的内核会对每个 CONNECT 回 405,$connect_host也不存在。 - Server 侧开关:
server.proxy_entry.enabled=true、domain_suffix、listen、trusted_proxies的最小配置片段(配置文件与环境变量两种写法),并强调改完先跑tunnelmesh-server --config <file> check-config。 - 渲染模板:7 个占位符与 3 个 Lua 行尾标记的取值来源表;随后贴出渲染完成的完整 server 块(用
tm.example.com作为示例后缀),让读者可以直接对照,不必自己推演替换结果。 - 安装步骤:复制 Lua 到
/etc/openresty/lua/tunnelmesh_proxy_entry.lua;把渲染后的upstream与server块并入既有nginx.conf的http{};在 main 上下文加worker_shutdown_timeout 300s;;nginx -t;nginx -s reload。明确worker_shutdown_timeout不能写在 server 块里。 - 容器方式(可选):
docker build -f deploy/openresty/Dockerfile.proxy-connect -t tunnelmesh/openresty-proxy-connect:1.25.3.1 .;替换生产 OpenResty 前必须先比对目标机nginx -V的--with-*参数并补进 Dockerfile 的configure,否则现有 server 块会因缺模块起不来;镜像默认非 root,监听 443 需要 root 或setcap cap_net_bind_service=ep。 - 验证:
curl -sv --proxy-insecure -x https://tp-demo.<domain_suffix> --proxy-user 'u:p' https://ifconfig.me(自签证书时用--proxy-insecure),预期返回的出口 IP 属于 agent 所在网络;再给一条 ACL 外主机的预期 403 与错误密码的预期 407。 - 容量评估:每条隧道 = 1 个 nginx 请求 + 2 个 socket(下游 + 内部入口),
worker_connections与worker_rlimit_nofile按server.proxy_entry.max_concurrent_tunnels × 2起评;Server 先拒绝超限请求(503 + Retry-After),nginx 侧limit_conn只是兜底,不要配得比 Server 更紧,否则排障时看到的是 nginx 的 503 而不是稳定错误码。 - reload 与发布影响:
nginx -s reload会让旧 worker 停止接受新连接,在途隧道在worker_shutdown_timeout窗口内排空,超时后被强制关闭;客户端表现为连接断开并重连。给出发布窗口建议(低峰期、先扩 Server 再 reload nginx)。 - 回滚(5 分钟内):删除渲染进去的
upstream与server块后nginx -s reload,入口立刻消失且不影响既有 admin/托管路由;或把server.proxy_entry.enabled置 false 并重启 Server(内部入口关闭后 nginx 侧表现为 502)。路由数据无需回滚:tunnels里的http-proxy行可以直接停用或删除,Schema 仍是 v13。 -
跨机部署内部入口:
listen绑内网地址、trusted_proxies收紧到 OpenResty 主机 IP、禁止0.0.0.0/0(check-config会拒绝);标注“不推荐,仅在无法同机时使用”,并说明此时内部段是明文,链路上的 Basic 凭据可被嗅探。 - Step 8: 写
docs/user-guide/http-proxy-entry.md
必须包含(面向管理员与使用者两类读者,与 managed-http-route.md 分工:那篇讲反代路由,这篇讲正向代理入口):
- 一句话定位:把
https://tp-<name>.<domain>填进浏览器或操作系统的“HTTPS 代理”,即可从任意允许的来源 IP 经指定 agent 出网或访问其内网服务,用户机器上不需要安装 tunnelmesh-client。 - 与 client 本地代理的区别表:
tunnelmesh-client forward socks5/forward http-proxy(本机监听、需装 client、支持 SOCKS5)vs 托管入口(无需安装、只支持 HTTP/HTTPS 代理、出口与 ACL 由管理员集中控制)。 - 管理员:后台操作:路由管理 -> 新建 -> 类型选“HTTP 代理入口 (tp-)”,逐项说明名称(只填
<name>,域名自动拼tp-前缀并实时预览)、出口 Agent、认证方式(无 / 用户名密码)、代理凭据(需先在“密钥管理”里建proxy_basic类型凭据,填 username 与 password)、源 IP ACL(默认拒绝所有,0.0.0.0/0放开,提供“放开全部来源”按钮)、目标网段/端口限制、允许访问内网目标开关、并发上限、备注。写明生效时间 <= 5 秒,不需要重启 Server,也不需要改 nginx。 - 管理员:API 方式:
POST /api/v1/routes的完整 JSON 示例(protocol: "http-proxy"、agentId、domain由服务端按 name 拼装或按 spec 直接给完整域名——以 Task 12 的实现为准,写文档前对照docs/api/openapi.yaml),以及三类 400 场景:传了targetHost/targetPort、在http-proxy与其它协议之间切换、CIDR 或端口非法。 - 使用者:各端配置:macOS(系统设置 -> 网络 -> 详细信息 -> 代理 -> 安全网页代理 HTTPS)、Windows(Internet 选项 -> 连接 -> 局域网设置 -> 代理服务器,勾选“对 HTTPS 使用相同代理”)、Chrome/Firefox PAC 示例、
curl -x示例、SwitchyOmega 之类插件的填法。明确写出只能填http://代理地址的旧客户端无法使用本入口(路由身份来自 TLS SNI,凭据也必须加密传输)。 - 错误码自助排查表:12 个稳定错误码逐条给出“你会看到什么 / 为什么 / 你能做什么”,与 Task 2 的
errors.go一一对应,不得漏项也不得新增未实现的码。 -
限制:不支持 SOCKS5;不支持公网 UDP 入口;一条路由只有一个出口 agent(无负载与故障转移);Basic 认证是路由级共享账号,没有 per-user 配额;隧道建立之后的失败只会表现为连接断开,不会再有状态码。
-
Step 9: 追加
docs/deployment/nginx.md - 在“关键约束”之前新增一节
## tp-* HTTP 代理入口(OpenResty):说明它与本文既有 server 块共用 443、由 SNI 分流;本文不重复模板内容,指向 OpenResty 代理入口部署 与deploy/openresty/;写明它要求 OpenResty(纯 nginx 没有 lua-nginx-module 就无法实现),以及 tp-* server 块必须省略http2,与既有块的 per-serverhttp2 on;互不影响。 - “关键约束”小节追加 3 条:
- tp-* server 块禁止
http2:ngx.req.socket(true)在 HTTP/2 下游不可用,proxy_connect 模块的 Known Issues 也明确不支持 HTTP/2 的 CONNECT。 location /必须显式proxy_set_header Proxy-Authorization $http_proxy_authorization;:它是 hop-by-hop 头,nginx 默认不转发给上游,漏掉会让所有非 CONNECT 请求 407。- 不能用
proxy_connect;+proxy_pass做链式转发:模块 README 明确 “Anylocation {}block,upstream {}block and any other standard backend/upstream directives, such asproxy_pass, do not impact the functionality of this module.”,模块会自己直连目标,路由身份与Proxy-Authorization全部丢失。本项目的做法是不启用proxy_connect;指令,只用补丁提供的$connect_host/$connect_port变量与 server 级access_by_lua_file把 CONNECT 搬到 Server 内部入口。
- tp-* server 块禁止
-
“验证命令”小节补一条:
curl -sv --proxy-insecure -x https://tp-demo.<domain_suffix> --proxy-user 'u:p' https://ifconfig.me。 - Step 10: 追加
docs/operations/troubleshooting.md
在文件末尾(## 管理后台打开是 Nginx / OpenResty 欢迎页 之后)新增 ## tp-* HTTP 代理入口失败,按“现象 -> 定位命令 -> 根因 -> 处理”四段式,至少覆盖:
curl报Received HTTP code 403 from proxy after CONNECT:三类原因(路由身份非法、路由不存在或停用、源 IP 不在 ACL)对外文案一致,只能靠 Server 侧区分——查tunnelmesh_proxy_entry_requests_total{result="denied"}的error_class,或看 Server 日志与审计事件proxy_route_denied的reason。特别提醒:Server 看到的是 OpenResty 传来的$remote_addr,如果入口前面还有一层 LB,需要让 LB 透传真实客户端 IP 并把server.proxy_entry.client_ip_header改成对应头,否则 ACL 会按 LB 的地址判定。- 407 且连续失败后长时间不恢复:
auth_backoff_threshold(默认 5)触发退避,30s 起每次翻倍、上限 15m;退避期内不做密码比对,直接 407。 - 405 Method Not Allowed:OpenResty 内核没有打 proxy_connect 补丁(
nginx -V检查),或模块与补丁版本不匹配。 - 502:内部入口不可达。依次确认
server.proxy_entry.enabled、listen与 conf 模板__INTERNAL_UPSTREAM__渲染结果一致、同机回环可达(curl -sv http://127.0.0.1:8089/应得到 Server 的 403 而不是 connection refused)。 - 503 +
Retry-After: 5:并发超限,查全局max_concurrent_tunnels与该路由config.maxConcurrentTunnels,以及tunnelmesh_proxy_entry_tunnels_active。 - 504:
connect_timeout内没有开流成功,查 agent 是否在线、集群模式下 relay 是否正常。 - 隧道建立后很快断开:Lua 的
read_timeout_ms必须等于 Serveridle_timeout+ 30s,配反了会出现 nginx 先掐断;确认deploy/openresty/openresty_artifacts_test.go是通过的。 - reload 后所有代理连接断开:main 上下文缺
worker_shutdown_timeout 300s;。 - 浏览器提示代理不支持 / 协商到 h2 失败:tp-* server 块被加了
http2,或客户端只能填http://代理地址。 - error.log 里大量
client aborted:属正常(客户端主动断开);但如果只有client aborted而没有对应的tunnel closed,说明lua_check_client_abort未开或ngx.on_abort未注册,隧道会挂到读超时才回收。 -
排障顺序固定为:OpenResty
error_log(info 级,含 route/target/client/字节数/时长/结束原因)-> Server 日志proxy_entry_*-> Prometheustunnelmesh_proxy_entry_*-> 审计日志proxy_tunnel_opened/proxy_tunnel_closed。三处都不得出现密码或完整Proxy-Authorization头,若出现按安全事故处理。 - Step 11: 更新入口索引与能力矩阵
docs/README.md:
- “使用者”列表在
[托管 HTTP 路由]之后加一行:- [HTTP 代理入口(tp-*)](user-guide/http-proxy-entry.md):把 https://tp-<name>.<domain> 填进浏览器或系统代理,无需安装 client。 - “部署”列表在
[Nginx/WSS 推荐配置]之后加一行:- [OpenResty tp-* 代理入口](deployment/openresty-proxy-entry.md):模板渲染、镜像构建、容量评估、reload 影响与回滚。 - “文档地图”表
deployment/行的“内容”列补上OpenResty 代理入口。
docs/user-guide/managed-http-route.md:在开头段落后加一句交叉引用——http-proxy 类型的路由不是反向代理,它由 SNI 选路由、目标由请求决定、target_host/target_port 存的是哨兵 */0,使用说明见 HTTP 代理入口。
docs/operations/completeness-checklist.md:已完成项末尾追加 - [x] tp-* 托管 HTTP 代理入口(OpenResty 搬运层 + Server 策略内核 + 管理后台);未完成项不动(SOCKS5 托管入口、单路由多出口池化仍属 deferred,不得写成已支持)。
docs/architecture/overview.md(spec 第 16 节要求的“入口拓扑图”,用紧凑文字链路代替图片,避免图与代码之间出现第二处需要同步的地方)。在第一段(三个可执行程序的职责)之后插入:
浏览器 / curl / 系统代理(HTTPS 代理方案,只能填 https://)
└─ TLS + SNI: tp-<name>.<domain_suffix> 复用既有 443,不新增公网端口
└─ OpenResty tp-* server 块:access_by_lua_block
· 打过 proxy_connect 补丁的内核对 CONNECT 跳过 location 匹配
· 只搬字节,不含任何策略判断
· 注入可信头 X-TunnelMesh-Route(来自 SNI)/ -Client-IP / -Client-Port
└─ 明文回环 127.0.0.1:8089(server.proxy_entry.listen)
└─ tunnelmesh-server:ProxyEntryListener
· trusted_proxies 前置校验,不在白名单则读请求前直接关闭
· proxyentry 策略链:身份解析 → 源 IP ACL → Basic 认证 → 目标校验 → 并发限额
└─ relay.NodeTransport.OpenStream(集群模式下自动跨节点)
└─ Agent 出口:目标侧二次 SSRF / 私网 / 端口校验后建立真实连接
同时在该文件“公网模式只需要 HTTP/HTTPS/WSS 入口”那段补一句:tp-* 代理入口不新增公网监听端口,与既有 443 由 SNI 分流;唯一策略执行点是 Server,OpenResty 与 Lua 不含授权逻辑。
不新建 ADR 文件:spec 第 16 节已声明 docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md 本身即 ADR 载体,docs/architecture/adr/README.md 由 Step 12 的索引脚本重生成;只有在 Task 0 判定回退 A2 或后续发生重大设计回退时才另行补记 ADR。
README.md:
- 能力列表(
forward socks5/forward http-proxy那一段)之后加一条 bullet:- Managed HTTP proxy entry — set \https://tp-. \` as a browser or OS proxy; the egress agent, Basic auth and source ACL are configured in the admin console, and nothing has to be installed on the user machine.` ## Deployment的- Edge:行补上[OpenResty tp-* proxy entry](docs/deployment/openresty-proxy-entry.md)。## Documentation的**User guides**列表加一条- [HTTP proxy entry](docs/user-guide/http-proxy-entry.md) — browser/OS proxy without installing the client。- ASCII 架构图不改:tp-* 入口复用既有 443、既有 Server 进程与既有 agent 出口路径,图中已有的 Server 框已经覆盖它,加框只会让图与代码产生第二处需要同步的地方。
README.zh-CN.md:按同样三处补中文(能力 bullet、Edge 行、User guides 列表),措辞与 docs/user-guide/http-proxy-entry.md 的定位句保持一致。
deploy/README.md:grafana/dashboards/tunnelmesh.json 那一行的“五个 Row 组织”改为“六个 Row 组织(Overview / Agent / Network / Cluster / Security / HTTP Proxy Entry)”。
docs/development/testing.md:
- 验证层级表在“浏览器端到端”行之后加一行:
| OpenResty 端到端 | TM_PROXY_E2E_NGINX=1 node test/e2e/proxy-entry/run.mjs | 真实 OpenResty 容器 + 内部入口替身,验证 CONNECT 搬运、请求头白名单、非 200 响应原样透传、绝对形式改写、客户端断开后隧道回收、日志不含凭据 |。 - 表后那段说明补一句:该冒烟需要 docker 与 openssl,未设置
TM_PROXY_E2E_NGINX=1时打印原因并以 0 退出;修改deploy/openresty/下任一产物后发布前必须跑一次,详见 test/e2e/proxy-entry/README.md。
docs/development/README.md:在 WebSSH 端到端那一行之后加 - [tp-* 代理入口 OpenResty 端到端测试](../../test/e2e/proxy-entry/README.md):真实 OpenResty 容器 + 内部入口替身。
- Step 12: 重新生成文档索引
Run: python3 scripts/gen_doc_index.py && git diff --stat docs/pull-requests/README.md docs/superpowers/plans/README.md docs/superpowers/specs/README.md docs/architecture/adr/README.md
Expected: 四份索引出现本 feature 的 plan / spec / pr 三条交叉引用;docs/README.md 不在生成范围内(手工维护,见 Step 11)。
再跑一次确认幂等:
Run: python3 scripts/gen_doc_index.py && git diff --check && git diff --stat docs/pull-requests/README.md
Expected: 第二次执行后 diff 不再变化。
- Step 13: 写 PR 记录
docs/pull-requests/2026-09-13-managed-route-http-proxy-entry.md,章节与 docs/pull-requests/2026-09-12-admin-webssh-sftp.md 完全一致:
- Title:
Managed-route HTTP proxy entry (tp-*)。 - Target Branch:
main。 - Summary:三段——用户视角(无需安装 client 即可用浏览器/系统代理)、架构视角(OpenResty 只搬字节,Server 是唯一策略执行点,出口经既有 relay 到 agent)、实现视角(
internal/proxyentry策略内核 +internal/server/proxy_entry*.go入口 + 复用tunnels/credentials表,Schema 保持 v13)。 - User Impact:新增能力清单(tp-* 路由、两种认证方式、源 IP ACL、目标 CIDR/端口限制、允许内网目标开关、并发上限、后台使用说明抽屉);明确不受影响的部分(既有托管路由、client 本地 SOCKS5/HTTP 代理、WebSSH)。
- API / Schema / Configuration Impact:
/api/v1/routes与/api/v1/credentials的新字段与http-proxy/proxy_basic枚举;Schema 无变更(v13,零迁移);新增server.proxy_entry的 13 个配置项及默认值;TUNNELMESH_SERVER_PROXY_ENTRY_*环境变量与--server.proxy_entry.*命令行等价。 - Security and Authorization Impact:可信头只由 OpenResty 写入且客户端同名头被丢弃;内部入口只绑回环 +
trusted_proxies前置校验 + 启动期禁止0.0.0.0/0;Basic 密码复用既有 AES-256-GCM secret store,列表与日志永不回显;routing.IsDangerousAddress恒拒 + agent 侧二次校验;认证失败指数退避;未知路由/停用路由/ACL 拒绝对外同文案防枚举。 - Test Evidence:逐条贴出 Step 14 的命令与实际输出摘要(通过数、耗时),并明确写出哪些项未在本机执行(例如需要 docker 的 E2E、需要
promtool的规则校验、需要TUNNELMESH_TEST_MYSQL_DSN的 MySQL contract),不得把未执行项写成已通过。 - Release Steps:先升级 Server(
server.proxy_entry.enabled=false时行为与旧版完全一致)-> 部署 OpenResty 产物并nginx -t-> 配 DNS 泛解析与通配证书 -> 打开enabled并check-config-> 后台建一条 tp-* 路由验证 -> 导入更新后的 Grafana Dashboard。 - Rollback Steps:5 分钟止损顺序——删除 nginx 里的 tp-* server 块并 reload(入口立即消失);或
enabled=false重启 Server;路由行可直接停用;Schema 无变更因此不涉及数据回滚。 - Reviewer Focus:
internal/proxyentry的 fail-closed 语义(ACL 空列表=拒绝、非法条目=拒绝)、internal/server/proxy_entry.go的 hijack 与spliceWithIdleTimeout半关闭处理、哨兵target_host="*"在loadManagedRoutes中被正确跳过(否则location /的泛域名反代会误匹配 tp-*)、Lua 里没有任何策略逻辑、日志与指标不含凭据。 - Integration Status:写明基线 commit(
git merge-base HEAD main)与本 PR 触及的文件清单来源(Task 0-15 的 commit 步骤);若期间有其它会话的提交进入main,逐条列出与本 PR 重叠的文件及处理方式,无重叠则明确写“无重叠”。 -
全文不得出现密码、Token、私钥、生产 DSN 或未脱敏日志。
- Step 14: 全量门禁
go test ./... -count=1
go test -race ./...
go vet ./...
gofmt -l internal/ cmd/ deploy/ test/
git diff --check
go test ./deploy/... -count=1
promtool check rules deploy/prometheus/alert-rules.yaml
node test/e2e/proxy-entry/run.mjs
cd web && npm test -- --run && npm run build && cd .. && ./scripts/verify-web-embed.sh
Expected: 全部通过;gofmt -l 与 git diff --check 无输出;node test/e2e/proxy-entry/run.mjs 打印 SKIP 并以 0 退出。有 docker 的环境再执行一次 TM_PROXY_E2E_NGINX=1 node test/e2e/proxy-entry/run.mjs,并把结果写进 PR 记录的 Test Evidence。
promtool 与 docker 都是可选依赖,缺失不算门禁失败:promtool 未安装时按 Step 5 的说明改用 python3 -c "import yaml,sys; yaml.safe_load(open('deploy/prometheus/alert-rules.yaml'))" 校验 YAML 语法;docker 缺失时 E2E 保持 SKIP。两者都必须在 PR 记录的 Test Evidence 里写成“未执行 + 原因”,不得写成已通过。go test -race ./... 与 go test ./... -count=1 是硬性门禁,不允许跳过。
- Step 15: 验收对照与 spec 偏差回写
(a) 逐条核对 spec 第 18 节的 6 条验收标准,把结论写进 PR 记录的 Test Evidence:
| spec 第 18 节 | 验证方式 | 证据 |
|---|---|---|
| 1. 后台建路由后 5 秒内生效,不重启 Server、不改 nginx | Task 7 的 TestManagedRouteTable*(http-proxy 行不进反代路由表、proxy 索引与 revision 正确)+ Task 12 的 TestAPIRouteProxyProtocol*(创建即可被快照读到);5s TTL 沿用既有 loadManagedRoutes 缓存,不新增测试 |
go test ./internal/server/ -run 'ManagedRoute\|APIRouteProxy' -count=1 -v 输出 |
| 2. ACL 内可用且出口 IP 属 agent 网络;ACL 外 403;密码错 407 且第 6 次进退避 | Task 4 的 ACL 用例、Task 5 的认证与退避用例、Task 10 的 handler 用例覆盖 403/407/退避;“出口 IP 属 agent 网络”只能在有真实 agent 的环境手工验证,命令写在 docs/user-guide/http-proxy-entry.md |
单测输出 + 手工 curl 记录 |
| 3. 无认证模式带错误凭据同样可用,ACL 仍生效 | Task 10 中 authMode=none 的用例(不带凭据成功、带错误凭据成功、ACL 外仍 403) |
单测输出 |
4. allowPrivateTargets=true 可访问内网;169.254.169.254 恒 403 |
Task 6 的 TestTargetPolicy* 全部用例 |
单测输出 |
| 5. 集群模式下 agent 挂在别的节点时隧道正常,trace hop 完整 | 隧道部分复用既有 relay 夹具的 Task 10 用例;POST /api/v1/agents/{agentId}/trace 是既有能力且本 PR 不改其实现,hop 完整性只能手工验证 |
单测输出 + 手工 trace 记录 |
| 6. 门禁全通过、第 16 节文档齐全、OpenAPI 与实现一致 | Step 14 的门禁命令 + Step 12 的索引重生成 + 把 docs/api/openapi.yaml 与 Task 12 实现的字段逐条对照 |
门禁输出 + 对照结论 |
任一条无法在本机验证的,必须在 PR 记录里写成“未验证”并给出验证前提(例如需要真实 agent、需要 docker、需要 TUNNELMESH_TEST_MYSQL_DSN),不得写成已通过。
(b) 回写 spec 的两处偏差(docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md):
- 第 13 节:管理操作审计事件从
proxy_route_created|updated|deleted改为“复用既有route.created/route.updated/route.deleted”。理由:internal/server/api.go的auditRoute已经为所有tunnels行写这三个动作,再加一套proxy_route_*会让同一次操作产生两条语义重复的审计记录,也让审计检索多一套命名。运行时的proxy_route_denied、proxy_auth_failed、proxy_tunnel_opened、proxy_tunnel_closed、proxy_request_forwarded五个事件名不变。 - 第 14 节:测试文件名改成计划实际使用的名字——第 5 条为
internal/server/proxy_entry_test.go与internal/server/proxy_entry_absolute_test.go,第 6 条为internal/server/api_proxy_route_test.go,第 7 条为internal/storage/credential_repository_test.go与internal/server/credential_api_test.go,并补一条internal/observability/metrics_proxy_entry_test.go;门禁命令的gofmt -l参数补上deploy/(Task 14 在deploy/openresty/下新增了 Go 测试文件)。第 11 条的容器与 stub 修正已由 Task 14 Step 15 写入,本步骤不重复。 -
第 15 节:把“spike 产物只作为结论文档记录,不进入生产代码”补一句例外——
deploy/openresty/spike-connect-check.sh作为只读诊断脚本提交进仓库并被部署文档引用,因为它不参与请求处理,而且是“这台机器的内核到底有没有打补丁”的唯一可执行判据;结论记录仍然写在本计划文件末尾的## Task 0 验证记录。 - Step 16: Commit(需授权)
git add deploy/grafana/dashboards/tunnelmesh.json deploy/grafana/dashboard_schema_test.go deploy/prometheus/alert-rules.yaml deploy/README.md docs/operations/observability.md docs/operations/troubleshooting.md docs/operations/configuration.md docs/operations/completeness-checklist.md docs/deployment/openresty-proxy-entry.md docs/deployment/nginx.md docs/user-guide/http-proxy-entry.md docs/user-guide/managed-http-route.md docs/architecture/overview.md docs/development/testing.md docs/development/README.md docs/README.md docs/pull-requests/2026-09-13-managed-route-http-proxy-entry.md docs/pull-requests/README.md docs/superpowers/plans/README.md docs/superpowers/specs/README.md docs/architecture/adr/README.md docs/superpowers/specs/2026-09-13-managed-route-http-proxy-entry-design.md README.md README.zh-CN.md
git commit -m "feat(observability): add proxy entry dashboard row, alerts and docs"
docs/operations/configuration.md 的 server.proxy_entry 配置表已由 Task 1 写入,这里只作为兜底加入 git add 列表;若 Task 1 已提交,该文件在本次 commit 中不会有变化,属正常。
Task 0 验证记录
执行环境:Darwin arm64,Go go1.27.1,Node v24.15.0,基线 main @ b3acd34。
(a) build flags —— 未执行
Run: nginx -V 2>&1 | tr ' ' '\n' | grep -i "proxy_connect\|add-module\|with-http_ssl"
未执行,原因是本机没有 OpenResty,也没有任何容器运行时可用来自建一个:
nginx (not found)
openresty (not found)
resty (not found)
docker (not found)
podman (not found)
colima (not found)
nerdctl (not found)
曾尝试 brew install openresty/brew/openresty 以后台方式安装,但后台进程随工具会话结束被回收(日志 0 字节)。即使安装成功,Homebrew 的 openresty formula 不带 ngx_http_proxy_connect_module 补丁,CONNECT 会被 nginx 直接回 405,跑出来的结果只反映本机安装方式、不反映目标机内核,因此没有继续。
(b)(c)(d) 明文 CONNECT 经 server 级 access_by_lua —— 未执行
Run: bash deploy/openresty/spike-connect-check.sh 18443
未执行(同上,缺 OpenResty 内核)。脚本本身已按 Step 1 写入 deploy/openresty/spike-connect-check.sh,并通过 bash -n 语法检查;shellcheck 本机未安装,未执行。
与计划的一处偏差:脚本用 run_with_timeout 包装了 nc,因为 macOS 没有 timeout(1)(只有 coreutils 的 gtimeout)。该脚本会作为部署前置检查在管理员本机运行,缺 timeout 时退化为“后台执行 + 定时 kill”,语义不变。
(e) 非 CONNECT 分支的 hop-by-hop 头 —— 未执行
未执行(同上)。计划已按“必须显式 proxy_set_header Proxy-Authorization $http_proxy_authorization;”编写,且该约束由 deploy/openresty/openresty_artifacts_test.go(Task 14)以契约测试守护,不依赖本次 spike 的结果。
已由上游源码核实的事实(Task 14 外部事实表,非本机经验验证)
proxy_connect_rewrite_102101.patch(模块 tagv0.0.7)把$connect_host/$connect_port注册进ngx_http_core_variables[],删除 nginx 对 CONNECT 的 405 拒绝,并在ngx_http_core_find_config_phase中r->phase_handler++跳过 location 匹配 —— 这正是 server 级access_by_lua能看到 CONNECT、而content_by_lua_block永不触发的机制。NGX_HTTP_PROXY_CONNECT宏由模块config定义,补丁与模块缺一都会让 CONNECT 失效。- OpenResty
1.25.3.1与该补丁成对(模块 README 的 Compatibility 表)。
决策:继续 A3(附强制前置条件)
不触发中止判据:中止判据是“spike 跑出来 access_by_lua 收不到 CONNECT / 拿不到 raw socket 或 SNI”,而本次是无法执行,不是执行后失败。机制层面已由上游补丁源码核实,A3 的核心假设成立。
但因为缺少经验验证,落地必须满足以下强制前置条件,缺一不可:
server.proxy_entry.enabled默认false(Task 1 已实现并有测试守护),代码合入后线上行为与旧版本完全一致,不存在“未验证即生效”的风险。- 在目标 OpenResty 主机上启用之前,必须先执行
bash deploy/openresty/spike-connect-check.sh 18443,确认输出包含spike connect_host=example.com connect_port=443且 nc 侧收到HTTP/1.1 200 Connection Established与spike-ok;把实际输出补记到本小节。 - 若目标机内核没有补丁(收到 405 或 error.log 无 spike 行),改用
deploy/openresty/Dockerfile.proxy-connect构建的内核,或按 spec 第 15 节回退 A2,并把决策写回 spec 第 17 节。 - Task 14 的 OpenResty 端到端冒烟(
TM_PROXY_E2E_NGINX=1 node test/e2e/proxy-entry/run.mjs)需要 docker,本机同样无法执行,按 skip 语义退出 0;必须在有 docker 的环境补跑,并把结果写进 PR 记录的 Test Evidence。
以上第 2、4 条在 PR 记录中必须标注为“未执行 + 原因”,不得写成已通过。
实施结果与偏离记录(Task 0-15 收尾回写)
执行环境:Darwin arm64,Go go1.27.1,Node v24.15.0。开发基线 main @ b3acd34;
推送前发现另一会话已向 origin/main 推入 4 个提交(91553a1、da994b3、6227c17、50b4130),
本 feature 的 17 个提交已 rebase 到 origin/main @ 50b4130 之上,无冲突,下表哈希为 rebase
后的最终值。重叠文件与处理方式见 PR 记录的 Integration Status。
本节是全计划的权威执行记录;PR 记录见
docs/pull-requests/2026-09-13-managed-route-http-proxy-entry.md。
计划正文里的 - [ ] 复选框不作为完成度跟踪手段(Task 0-12 期间也未勾选),完成情况以本节的
提交对照表为准;这样避免“复选框已勾但实际未执行”与“实际已执行但忘记勾选”两类不一致。
提交对照
| Task | 提交 | 说明 |
|---|---|---|
| 计划细化 + Task 0 spike | 9981401 |
含 Task 0 验证记录 |
| Task 0 诊断脚本 | cb1abfe |
deploy/openresty/spike-connect-check.sh |
| Task 1 配置项 | 3dce406 |
server.proxy_entry 13 个键 |
| Task 2 错误模型与路由类型 | 4332a24 |
internal/proxyentry/errors.go、route.go |
| Task 3 路由身份解析 | 97535be |
header 与 SNI 两种实现 |
| Task 4 源 IP ACL | 2122f21 |
fail-closed |
| Task 5 Basic 认证与退避 | d54e786 |
常量时间比较 + 指数退避 |
| Task 6 目标地址校验 | 342e739 |
SSRF / 私网 / 端口策略 |
| Task 7 proxy 路由快照 | 700ecf0 |
http-proxy 行不进反代路由表 |
Task 8 proxy_basic 凭据 |
4c98af3 |
复用既有 secret store |
| Task 9 内部监听与可信 peer | 3503fd2 |
读请求前直接关闭不可信 peer |
| Task 10 CONNECT 隧道 | 3911b0b |
限额、指标、审计 |
| Task 11 绝对形式转发 | c7d9fc9 |
非 CONNECT 分支 |
| Task 12 管理 API | 72416d7 |
同时修掉 Task 9 的 -race 缺陷(见下) |
| Task 13 管理后台 | dbc0fc5 |
Vue + i18n + 前端测试 |
| Task 14 OpenResty 产物 | 22527be |
Lua / conf / Dockerfile / E2E |
| Task 15 收尾 | 本次提交(HEAD) | Dashboard row、告警、全部文档、PR 记录;本表自身也在这次提交里更新,因此不写死哈希 |
门禁结果(Task 15 Step 14)
| 命令 | 结果 |
|---|---|
go test ./... -count=1 |
20 个包全部 ok,0 FAIL |
go test -race -timeout 40m ./... |
退出码 0,20 个包全部 ok;最慢 internal/server 291.594s |
go vet ./... |
退出码 0,无输出 |
gofmt -l internal/ cmd/ deploy/ test/ |
无输出 |
git diff --check |
无输出 |
go test ./deploy/... -count=1 |
grafana / install / openresty 全部 ok |
cd web && npm test -- --run |
30 文件、243 用例通过 |
cd web && npm run build |
成功并镜像到 internal/server/web_dist |
./scripts/verify-web-embed.sh |
web/dist and internal/server/web_dist match |
python3 scripts/gen_doc_index.py |
四份索引重生成,二次执行幂等 |
node test/e2e/proxy-entry/run.mjs |
SKIP(未设 TM_PROXY_E2E_NGINX),退出码 0 |
TM_PROXY_E2E_NGINX=1 node test/e2e/proxy-entry/run.mjs |
SKIP(缺 docker),退出码 0 |
未执行项(必须在具备条件的环境补跑,不得记为已通过)
- Task 14 Step 13:
TM_PROXY_E2E_NGINX=1的完整 13 条断言。原因是本机无 docker 且拉不到openresty.org/github.com。已按计划的中止判据降级为手工步骤,并在deploy/openresty/README.md与test/e2e/proxy-entry/README.md顶部标注验证状态。未放宽任何断言。 - Task 14 Step 5 的镜像构建:同上,
Dockerfile.proxy-connect未经本机构建验证;版本 pin 与./configure → patch → make顺序由openresty_artifacts_test.go静态守护。 - Task 15 Step 5 的
promtool check rules:本机无promtool,改用ruby -ryaml解析, 结果groups=1 rules=9(既有 7 条 + 新增 2 条)。 - 真实 MySQL contract:未设置
TUNNELMESH_TEST_MYSQL_DSN,只跑了 SQLite 方言。 - spec 第 18 节验收标准第 1、2、5 条的现网部分:需要真实 Agent、真实 OpenResty 与 DNS, 已在 PR 记录的验收对照表里逐条标注“部分验证 / 未验证”。
- Task 0 强制前置条件第 2 条(在目标 OpenResty 主机执行
spike-connect-check.sh并把输出 补记到## Task 0 验证记录)仍然待办:启用server.proxy_entry.enabled之前必须执行。
逐任务偏离
- Task 12:额外修复 Task 9 引入的
-race缺陷——runtime.go的ServeListener返回前没有 等待代理入口解绑,新增entryDone同步。 - Task 13:① 列表“活跃隧道数”列改为抽屉内指向 Grafana(Row
HTTP Proxy Entry、面板Proxy tunnels active),因管理 API 未暴露该聚合,已回写 spec §10 与 §17;② 代理地址/认证方式/ ACL 三列用v-if="hasProxyRoutes"只在存在 tp-* 路由时渲染,避免整页横向滚动条; ③buildProxyRouteUpdate()只提交策略字段,不带targetHost/targetPort(PATCH 只接受哨兵值); ④ 顶层 i18n 补credentials.proxyBasic以镜像既有sshPublicKey约定;⑤ 抽屉补充 macOS/Windows/PAC/curl/错误码等 i18n 键(中英对齐),超出计划列出的键清单; ⑥ 计划文件清单里的internal/server/web_dist未纳入提交:该路径被.gitignore的internal/server/web_dist/*忽略且历史上从未被跟踪,构建产物由npm run build+scripts/verify-web-embed.sh本地生成校验。 - Task 14:①
deploy/openresty/README.md收录了 Task 0 留下的spike-connect-check.sh, 计划的“四个文件”实际是五个产物,deploy/README.md的校验小节同步补了bash -n一行; ② spec §5.1 不再内联完整 nginx 配置,改为引用模板产物 + 结构约束清单;③ spec §14 第 11 条除改成 Node stub 外,把镜像来源从openresty/openresty:alpine更正为自建Dockerfile.proxy-connect镜像——未打补丁的官方镜像对 CONNECT 一律 405;④test/e2e/proxy-entry/README.md用中文 (与docs/、deploy/README.md一致),章节结构对齐test/e2e/webssh/README.md; ⑤ Lua/conf/Dockerfile/mjs 由脚本从本计划的代码块逐字提取以保证与已批准计划一致,Go 测试文件 提取后执行gofmt -w(计划代码块是 4 空格缩进)。 - Task 15:①
docs/operations/observability.md的“应用已占用标签名”清单补了route(计划只要求改“标签和留存”小节,但route是新引入的应用标签,不进这份清单会让 target label 覆盖告警失效);②docs/development/testing.md开头“三层验证”改为“各层验证”,并在产物一致性 测试表补deploy/openresty/openresty_artifacts_test.go一行(计划未列,但该表就是这份清单的 权威来源);③docs/README.md的文档地图同时在user-guide/行补了“HTTP 代理入口” (计划只要求deployment/行);④ 顺带修正docs/operations/configuration.md中auth_backoff_threshold的错误描述——Task 1 写成“返回 429”,实现返回的是 407 与稳定错误码proxy_auth_backoff;⑤docs/architecture/overview.md的链路图把access_by_lua_block更正为server 级 access_by_lua_file,与实际产物一致;⑥ 不新建 ADR 文件,spec 本身即 ADR 载体 (与计划一致)。
rebase 后的复验
rebase 引入了远端对 web/src/i18n/messages/{zh-CN,en-US}.ts(downloads 键)、
web/src/views/Downloads.vue、web/src/layouts/AppShell.vue 与两份前端测试的修改,因此重跑了前端
门禁:npm test -- --run 30 文件 / 244 用例通过(rebase 前为 243,远端新增 1 例)、
npm run build 成功并镜像到 internal/server/web_dist、./scripts/verify-web-embed.sh 输出
web/dist and internal/server/web_dist match。Go 侧未受远端提交影响(远端只改了 web、CI workflow
与一篇文档),go vet ./...、gofmt -l、git diff --check 与 go test ./deploy/... -count=1
在 rebase 后重跑均通过。