配置说明
配置说明
配置来源
支持 YAML、JSON、TOML 配置文件,也可以完全不使用配置文件:
tunnelmesh-server --mode local --storage.driver sqlite --storage.auto_init=true check-config
优先级从高到低:
- 命令行参数
- 环境变量(前缀
TUNNELMESH_,点号和连字符转换为下划线) - 配置文件
- 内置默认值
Server、Agent 和 Client 的完整 YAML 可参考三端配置文件示例。
监听地址与 TLS
Server 只监听一个地址:server.http_addr。管理 API、Web 后台、Agent WebSocket(/ws/agent)、Client WebSocket(/ws/client)、WebSSH(/ws/webssh/<session-id>)、TCP bridge(/ws/tcp)和动态 HTTP 路由都复用该监听器。是否加密由 tls 段决定,作用在同一个监听器上:
server:
# 反向代理终止 HTTPS 时使用非特权端口,例如 127.0.0.1:8080。
http_addr: 127.0.0.1:8080
tls:
# Server 直接对公网提供 HTTPS 时设为 true,并同时给出证书和私钥。
enabled: false
cert_file: ""
key_file: ""
# 只接受 1.2 或 1.3。
min_version: "1.2"
tls.enabled: true 但缺少 cert_file/key_file,或 min_version 不是 1.2/1.3,run 会直接失败而不是降级为明文。生产部署推荐由 Nginx/Caddy 终止公网 TLS,Server 保持 tls.enabled: false 并只监听 loopback。
以下键仍被配置加载器接受,但当前版本不会建立额外监听器,属于历史遗留,不要依赖它们:
server.https_addrserver.agent_ws_addrserver.client_ws_addr
server.tcp_bridge_enabled 是 server.tcp_bridge.enabled 的扁平兼容写法,只在配置文件中生效;命令行、环境变量或 Set 覆盖中任一处出现规范键或扁平键时,扁平别名不再二次应用。环境变量两种写法都映射到 TUNNELMESH_SERVER_TCP_BRIDGE_ENABLED。
管理入口的超时、上限与保留策略
管理监听器用一个端口承载后台页面、/api/v1、Agent/Client WebSocket 和 WebSSH。这决定了它的边界不能照搬普通 HTTP 服务:一条已升级的连接要活几个小时,而一个恶意或缓慢的客户端又必须在几秒内被切断。server.http.* 就是这个折中——限制”请求头 + 请求体 + keep-alive 空闲”,不限制已建立连接的存活时间。
server:
http:
read_header_timeout: 10s
idle_timeout: 120s
max_header_bytes: 1048576
body_timeout: 30s
max_connections: 0
agents:
max_connections_per_agent: 64
metrics:
token: ""
audit:
retention_days: 0
| 键 | 默认值 | 含义 | 是否必填 |
|---|---|---|---|
server.http.read_header_timeout |
10s |
读取请求头的预算,slowloris 的第一道防线 | 否 |
server.http.idle_timeout |
120s |
keep-alive 连接空闲多久后关闭。正在传输的请求不受影响 | 否 |
server.http.max_header_bytes |
1048576 |
单个请求头块字节上限,超出直接断开 | 否 |
server.http.body_timeout |
30s |
只作用于 /api/v1 的单个请求体读取预算,按请求设置读截止并在响应结束后清除 |
否 |
server.http.max_connections |
0 |
管理监听器同时保有的连接数上限,0 表示不限制;超限的新连接立即关闭而不排队 |
否 |
server.agents.max_connections_per_agent |
64 |
单个 Agent 身份在本节点可保有的物理 WebSocket 数,达到上限后新连接被拒绝(session: agent connection capacity reached),既有连接不受影响。0 表示「取默认 64」而不是「不限」,见下 |
否 |
server.metrics.token |
空 | /metrics 的 Bearer 凭据。空值表示匿名可抓取(与历史行为一致);设置后必须不少于 16 字符 |
否 |
server.audit.retention_days |
0 |
审计日志保留天数。0 表示永久保留;只有正数才会启动清理任务 |
否 |
- 为什么没有
read_timeout/write_timeout:这两个是连接级预算,会把所有长连接(WebSocket、WebSSH、大文件下载)一起切断,因此代码里刻意不设置。等价能力由上表的”请求头预算 + 每请求体预算 + 空闲预算”组合提供。 0的规则:容量与保留类键的0统一表示”关掉这条限制”——server.http.max_connections=0不限连接数、server.audit.retention_days=0永久保留、server.stream.max_active_per_agent=0与agent.streams.max_active=0不限活跃流。两类例外:server.http的四个时间/字节预算与server.agents.max_connections_per_agent的0表示”用内置安全默认值”(负数一律在check-config阶段被拒绝)。原因很简单:超时预算为 0 会导致功能不可用,Agent 连接上限必须是一个有限数才能回 ACK、才能作为水位暴露;而”不限”对它们来说不是合法诉求。写配置时不要靠0去关闭某条超时——需要放宽就填一个大值。body_timeout不覆盖 WebSocket 路径:/ws/agent、/ws/client、/ws/webssh/、/ws/tcp不经过/api/v1包装器。已认证的 Agent/Client WebSocket 另有空闲读预算(三个 30s 心跳周期,见internal/server/runtime.go的websocketIdleReadTimeout),半开连接会在那里被发现,而不是靠 HTTP 超时。- 与反向代理的取值关系:
server.http.idle_timeout必须 不小于 Nginx upstream 的keepalive_timeout,否则 Nginx 会复用一条 Server 已关闭的连接并回 502;server.http.max_connections只在 Server 直接暴露公网(没有 Nginx 或location /直连)时才需要设成与进程容量匹配的正数,Nginx 在前时容量由limit_conn决定,保持0即可。完整取值依据见 Nginx 推荐配置。 max_connections_per_agent与 Agent 侧的关系:agent.connections.max(允许 1–512)是 Agent 自己愿意开的连接数,本键是 Server 愿意接受的数量,两者不自动对齐,也不会启动期交叉校验——它们在不同主机上、生效顺序不保证。把 Server 侧调小只会让超出的连接被拒并退避重连,不会踢掉既有连接。抬升 Agent 池的配方:agent.connections.max: 256时把server.agents.max_connections_per_agent设成不小于 256,否则 Agent 会一直撞到agent connection capacity reached。本键没有上界校验,因为它按 Agent 身份 计数,而一个身份可以由多个物理进程共享(instance_id区分):取值要按「同身份进程数 × 每进程connections.max」估算,可以高于 Agent 侧的 512 上限。水位可以观察:tunnelmesh_agent_connection_capacity是 Server 实际强制的上限,Agent 详情页的「连接数」与「活跃连接」是实际持有的数量,两者不相等就说明有连接在被拒。Agent 收到 metadata ack 时若发现回传上限低于自己配置的池子,会打一条每个不同上限值只说一次的 WARN:agent connection pool exceeds the server ceiling(字段configured_max、server_max),这是 Agent 侧唯一能自动发现该错配的地方——启动期刻意不跨主机校验。被拒的连接在指标里是tunnelmesh_connections_total{component="server",mode="agent",result="failed",error_class="capacity"},并写入审计(actionagent.connection.rejected)。metrics.token与网络白名单二选一即可:/metrics输出含指标名与标签值,能反推内部拓扑。若已经由反向代理按来源网段放行,就不必再配 token;若 Server 直接暴露,应用层凭据比依赖运维改 nginx 更可靠。健康探针/health/live、/health/ready永远不需要该 token,凭据不会被用来让 LB 误判节点死亡。抓取侧配置见deploy/prometheus/prometheus.yml.example。- 审计保留是显式决策:审计日志是证据,
retention_days默认0让升级不会删掉任何历史。启用后由后台每小时执行一次分批删除(单事务 1000 行),首次清理历史大表不会长时间锁表;集群每个节点各跑各的,语句本身幂等,因此不需要分布式锁。删除记录只输出条数与耗时,不含行标识。
上述键都是启动期生效,改动需要重启 Server,不参与管理后台热更新。命令行与环境变量等价:
export TUNNELMESH_SERVER_HTTP_IDLE_TIMEOUT=120s
export TUNNELMESH_SERVER_HTTP_MAX_CONNECTIONS=20000
export TUNNELMESH_SERVER_AGENTS_MAX_CONNECTIONS_PER_AGENT=32
export TUNNELMESH_SERVER_METRICS_TOKEN="$(openssl rand -hex 32)"
export TUNNELMESH_SERVER_AUDIT_RETENTION_DAYS=365
tunnelmesh-server --server.http.body_timeout=30s check-config
server.metrics.token 与 storage.mysql.dsn、tls.key_file 同属敏感项:config dump 与 RedactedJSON 输出会把它替换成 [redacted],json/yaml 序列化直接省略该字段,日志也只记录”是否配置”,不记录值。
本地模式示例
mode: local
storage:
driver: sqlite
auto_init: true
sqlite:
path: /var/lib/tunnelmesh/tunnelmesh.db
registry:
type: database
server:
# 单监听器;对公网直接提供 HTTPS 时改为 :443 并开启 tls.enabled。
http_addr: 127.0.0.1:8080
# 动态托管域名格式:<agent-id>-<a>-<b>-<c>-<d>-<port>.<dynamic_suffix>
dynamic_suffix: apps.example.com
tcp_bridge:
enabled: true
path: /ws/tcp
max_bytes: 65536
webssh:
enabled: true
ticket_ttl: 30s
session_ttl: 8h
max_active_sessions_per_user: 5
open_timeout: 10s
idle_timeout: 5m
max_message_bytes: 65536
# 后台“发行管理”页展示的 GitHub 发行仓库,格式固定为 owner/name。
downloads:
github_repository: nnworld/TunnelMesh
security:
# 管理 API 与 WebSocket 的 Host 白名单;留空表示不限制。
allowed_hosts: []
# 浏览器 Origin 白名单;开启 WebSSH/SFTP 时必须包含后台精确 Origin。
allowed_origins: []
# Deprecated: 仅用于迁移期兼容旧管理 token 连接 Agent,计划在 v0.3.0 移除。
allow_legacy_connection_tokens: false
tunnelmesh-server --config tunnelmesh.local.yaml check-config
tunnelmesh-server --config tunnelmesh.local.yaml run
集群模式示例
mode: cluster
storage:
driver: mysql
auto_init: true
mysql:
dsn: tunnelmesh:${MYSQL_PASSWORD}@tcp(mysql:3306)/tunnelmesh?parseTime=true&tls=true
# 可选;关闭时 DSN 中不要设置 tls=true
tls: false
ca: /run/secrets/mysql-ca.pem
registry:
type: database
node:
id: server-1
集群模式允许 MySQL 明文连接(storage.mysql.tls: false),但生产环境建议启用 TLS,并在 DSN 中设置 tls=true。关闭 TLS 时,DSN 里也不能残留 tls=true;程序会根据 storage.mysql.tls 覆盖 DSN 中的 tls 参数。
若配置文件中没有 node.id,且没有命令行或环境变量覆盖,Server 的 run 命令会生成小写 server-<32位十六进制> 身份,先复用 /var/lib/tunnelmesh/node-id,再把最终值回写到 YAML 的 node.id。回写采用原子替换,保留注释、权限和 owner;已有 node.id 或显式覆盖时不会改写。check-config、print-config 等只读命令不会修改文件。也可以显式执行:
tunnelmesh-server --config /etc/tunnelmesh/server.yaml init-node-id
持久化路径默认是 /var/lib/tunnelmesh/node-id,可用 --node-id-path <file> 覆盖。
用户级安装必须覆盖它:systemd user 单元、macOS LaunchAgent 与一键安装脚本的 user 模式都以
非 root 运行,写不了 /var/lib/tunnelmesh,init-node-id 会以 permission denied 失败。
打包的 deploy/systemd-user/tunnelmesh-server.service 已内置
--node-id-path __STATE_DIR__/node-id(渲染后指向 ~/.local/share/tunnelmesh/node-id),
一键安装脚本在校验阶段也传同一个路径;node.id 回写进 YAML 后,run 会直接复用而不再访问默认路径。
systemd 打包单元会在非特权 check-config 和 run 之前,以 root 执行一次 init-node-id。因此 /etc/tunnelmesh 对服务用户可以保持只读,/var/lib/tunnelmesh 仍由服务用户写入。生成 node.id 后再签发 relay 证书;mTLS 证书 SAN 必须包含最终 node.id 的精确条目,并同时包含所有节点共同的 server.relay.server_name。
切换 etcd:
registry:
type: etcd
endpoints:
- https://etcd-1:2379
- https://etcd-2:2379
WebSSH/SFTP 会话
管理后台的 WebSSH/SFTP 使用一次性 ticket 建立 /ws/webssh/<session-id> 二进制 WebSocket。Server 只转发字节,不在服务端保存或解析 SSH 密码、私钥、终端输出或 SFTP 文件内容;浏览器负责 SSH/SFTP 协议。
server:
webssh:
enabled: true
ticket_ttl: 30s
session_ttl: 8h
max_active_sessions_per_user: 5
open_timeout: 10s
idle_timeout: 5m
max_message_bytes: 65536
所有字段也可以通过命令行覆盖,例如:
tunnelmesh-server --server.webssh.enabled=true \
--server.webssh.ticket_ttl=30s \
--server.webssh.session_ttl=8h \
--server.webssh.max_active_sessions_per_user=5 \
--server.webssh.open_timeout=10s \
--server.webssh.idle_timeout=5m \
--server.webssh.max_message_bytes=65536
ticket_ttl 只表示 ticket 从创建到首次连接的等待时间;连接成功后由 session_ttl、idle_timeout 和用户主动关闭控制生命周期。idle_timeout 依赖底层 WebSocket 读超时能力;SSH keepalive 产生的流量会刷新该超时。security.allowed_origins 必须包含管理后台的精确 Origin,否则握手会被拒绝。
浏览器 SFTP 在客户端内分块传输,默认单文件上限为 1 GiB。该限制独立于 Nginx 的 client_max_body_size,因为文件不通过管理 API 上传;如果部署反向代理其它大请求路径,请分别评估限制。
终端内的 lrzsz(ZMODEM)收发不需要任何服务端开关:协议栈运行在浏览器中,Server 只转发字节。单个 WebSocket 帧仍受 max_message_bytes 约束(默认 64 KiB,上限 1 MiB),lrzsz 的数据块远小于该值,因此无需为 ZMODEM 调大该配置。ZMODEM 传输不走 SFTP 的 1 GiB 单文件限制,但接收方向会在浏览器内存中缓冲整个文件,超大文件请改用 SFTP 或其它带外通道。
凭据自动认证
远程服务器绑定带有认证秘密的凭据(password 类型,或已保存私钥的 ssh_public_key 类型)后,浏览器进入 WebSSH/SFTP 时可直接完成认证,不再弹出密码窗口。该能力依赖 Schema v13 与可恢复秘密加密密钥:
# base64(32 字节)或 hex;只能由环境变量或 Secret Manager 注入
TUNNELMESH_TOKEN_ENCRYPTION_KEY=$(openssl rand -base64 32)
TUNNELMESH_TOKEN_ENCRYPTION_KEY_ID=prod-2026-09
密钥同时用于 service token 的可恢复密文和凭据秘密密文,集群内所有 Server 节点必须一致,否则某个节点无法解密在其它节点写入的秘密。未配置密钥时 Server 正常启动,但创建带秘密的凭据会快速失败并返回 503,自动认证不可用并回退到手动输入密码;Server 不会静默降级为明文存储。轮换密钥会使旧密文不可解密,受影响凭据需要重新填写秘密,因此请把密钥版本记录在 Secret Manager 中。
中继流控没有配置项,属于协议内置约定,改动会破坏 Server 与 Agent 的兼容性:
| 常量 | 值 | 位置 |
|---|---|---|
MaxStreamFrame |
32 KiB | internal/protocol/window.go |
DefaultServerReceiveWindow |
512 KiB | 同上,Server 在 OPEN_STREAM 中通告 |
DefaultAgentReceiveWindow |
256 KiB | 同上,Agent 通告并对超额直接 RESET |
DefaultWindowUpdateThreshold |
128 KiB | 同上,两端每消费这么多字节回补一次窗口 |
浏览器侧的 1 MiB 发送高水位与 64 MiB 接收队列上限同样是内置值(web/src/webssh/byte-stream.ts、web/src/webssh/ssh-client.ts)。server.webssh.max_message_bytes 只限制单个 WebSocket 帧大小,与上述窗口无关,不要用它来“调大传输能力”。
tp-* HTTP 代理入口
托管路由的 HTTP 代理入口让浏览器/系统直接把 https://tp-<name>.<domain_suffix> 当作标准 HTTPS 代理使用,出口 agent、Basic 认证与来源 ACL 全部由管理后台的 http-proxy 路由决定,用户机器上不需要安装 tunnelmesh-client。
公网侧仍然只有 443:OpenResty 上的 tp-* server 块用打过 ngx_http_proxy_connect_module 补丁的内核接管 CONNECT,只搬字节并注入可信头,然后把请求送到下面这个进程内明文监听。所有策略判断(路由身份、来源 ACL、Basic 认证、目标校验、并发限额、审计、指标)都在 Server 里完成,Lua 不含任何授权逻辑。部署与渲染步骤见 OpenResty tp-* 代理入口,使用方式见 HTTP 代理入口。
server:
proxy_entry:
enabled: false
listen: 127.0.0.1:8089
trusted_proxies: ["127.0.0.1/32", "::1/128"]
domain_suffix: tm.example.com
connect_timeout: 10s
idle_timeout: 300s
shutdown_timeout: 30s
max_concurrent_tunnels: 512
max_header_bytes: 16384
auth_backoff_threshold: 5
| 键 | 默认值 | 含义 | 是否必填 |
|---|---|---|---|
server.proxy_entry.enabled |
false |
总开关。关闭时不创建内部监听,行为与旧版本完全一致 | 否 |
server.proxy_entry.listen |
127.0.0.1:8089 |
内部明文监听地址,必须与 OpenResty 模板的 __INTERNAL_UPSTREAM__ 渲染结果一致 |
否 |
server.proxy_entry.trusted_proxies |
["127.0.0.1/32","::1/128"] |
允许写入可信头的对端 CIDR。不在白名单的对端在读请求之前直接断开 | 否 |
server.proxy_entry.domain_suffix |
空 | tp-* 路由的域名后缀,必须与泛解析 DNS 和通配证书一致 | enabled=true 时必填 |
server.proxy_entry.route_header |
X-TunnelMesh-Route |
携带路由身份(来自 SNI)的可信头名 | 否 |
server.proxy_entry.client_ip_header |
X-TunnelMesh-Client-IP |
携带真实来源 IP 的可信头名;缺失或非法一律 403,绝不回退到对端 IP | 否 |
server.proxy_entry.client_port_header |
X-TunnelMesh-Client-Port |
携带真实来源端口的可信头名 | 否 |
server.proxy_entry.connect_timeout |
10s |
开流(到 agent 建立目标连接)的超时,超时返回 504 | 否 |
server.proxy_entry.idle_timeout |
300s |
隧道双向空闲超时;OpenResty 侧 read_timeout_ms 必须等于该值 + 30s |
否 |
server.proxy_entry.shutdown_timeout |
30s |
进程退出时等待在途隧道排空的时间,可为 0 | 否 |
server.proxy_entry.max_concurrent_tunnels |
512 |
全局并发隧道上限,超限返回 503 + Retry-After: 5;0 表示不限 |
否 |
server.proxy_entry.max_header_bytes |
16384 |
单个请求头上限,超出直接断开 | 否 |
server.proxy_entry.auth_backoff_threshold |
5 |
同一路由连续认证失败达到该次数后进入退避(30s 起翻倍,上限 15m)。退避期内不做密码比对,直接返回 407 与稳定错误码 proxy_auth_backoff |
否 |
trusted_proxies 支持 0.0.0.0/0 或 ::/0。若 listen 不是回环地址,这表示信任所有能访问该端口的主机,它们都可能伪造路由身份与来源 IP;生产环境必须用防火墙、安全组或专线限制内部入口的访问范围。命令行与环境变量等价(TUNNELMESH_SERVER_PROXY_ENTRY_*),例如:
tunnelmesh-server --server.proxy_entry.enabled=true \
--server.proxy_entry.domain_suffix=tm.example.com \
--server.proxy_entry.listen=127.0.0.1:8089 \
--server.proxy_entry.idle_timeout=300s \
--server.proxy_entry.max_concurrent_tunnels=512
改完先用 tunnelmesh-server check-config 校验再重启。路由本身(出口 agent、认证方式、来源 ACL、目标限制)不在这个配置文件里,全部由管理后台的托管路由维护,创建后 5 秒内生效,不需要重启 Server 或改 nginx。
内嵌 VPN 网关(WireGuard)
内嵌 VPN 网关让没有安装 tunnelmesh-client 的用户直接用系统自带的 WireGuard 客户端接入内网,出口仍由 Agent 承担。决策背景与边界见 ADR 0002。
数据面在 -tags vpn 构建里。 不带该 tag 的二进制只有管理面:server.vpn 仍会被完整加载与校验,管理 API 可以签发、列出、修改、轮换、吊销并审计 peer,但没有 WireGuard 端点,config:reveal 返回 409 vpn_node_disabled(节点没有自己的网关身份,渲染出来的 [Peer] PublicKey 会是空的,与其下发一个导入即失败的配置文件不如明确拒绝)。带 tag 的二进制在 enabled: true 时会真正监听 listen 指定的公网 UDP 端口并承载隧道。
两种构建都不允许「配了 enabled: true 却悄悄不工作」:无 tag 构建在该配置下启动即失败并报 this binary was built without VPN support; rebuild with -tags vpn,而不是报告 ready 然后让每个 peer 超时。构建变体、体积实测与放行步骤见 VPN 网关部署,容量、租约与 error_class 对照见 VPN 网关运维。
与 tp-* 代理入口不同,VPN 端点是一个独立的公网 UDP 端口:不经反向代理、不参与 HTTP 路由,必须在防火墙或安全组里单独放行,并单独限流与监控。
server:
vpn:
enabled: false
listen: 0.0.0.0:51820
endpoint_host: gw-1.mesh.example.com
ip_pool: 10.64.0.0/16
node_subnet_size: 24
mtu: 1420
max_peers: 0
max_flows_per_peer: 128
max_flows_total: 0
packet_rate_per_peer: 0
connect_timeout: 10s
idle_timeout: 120s
shutdown_timeout: 15s
icmp_enabled: true
icmp_timeout: 5s
icmp_max_concurrent: 64
| 键 | 默认值 | 含义 | 是否必填 |
|---|---|---|---|
server.vpn.enabled |
false |
总开关。关闭时进程不创建任何 VPN 资源,行为与旧版本完全一致;管理 API 的写操作返回 409 vpn_node_disabled,读操作返回空列表 |
否 |
server.vpn.listen |
0.0.0.0:51820 |
WireGuard 端点的公网 UDP 监听地址,需独立放行 | 否 |
server.vpn.endpoint_host |
gw-1.mesh.example.com |
下发给用户的 Endpoint 域名,只写主机名、不带端口;端口始终取自 listen,二者不会互相矛盾 |
enabled=true 时必填 |
server.vpn.ip_pool |
10.64.0.0/16 |
peer 地址池。必须是 IPv4,且不得落在链路本地、未指定或组播段;可以使用 CGNAT 段(如 100.64.0.0/16) |
否 |
server.vpn.node_subnet_size |
24 |
每个 Server 节点从池里切出的子网前缀。必须严格窄于 ip_pool 的前缀且不窄于 /30;切出的子网总数上限 4096 |
否 |
server.vpn.mtu |
1420 |
隧道 MTU,取值 576–1500。1420 = 1500 − WireGuard 的 72 字节开销 | 否 |
server.vpn.max_peers |
0 |
单节点 peer 上限,触顶时签发返回 503 vpn_capacity_exhausted;0 表示不限 |
否 |
server.vpn.max_flows_per_peer |
128 |
单 peer 并发流上限,超限计入 capacity_exhausted |
否 |
server.vpn.max_flows_total |
0 |
节点并发流总上限;0 表示不限 | 否 |
server.vpn.packet_rate_per_peer |
0 |
单 peer 每秒包数上限,超限计入 rate_limited;0 表示不限 |
否 |
server.vpn.connect_timeout |
10s |
开流(经 Agent 建立目标连接)的超时 | 否 |
server.vpn.idle_timeout |
120s |
流空闲回收时间 | 否 |
server.vpn.shutdown_timeout |
15s |
进程退出时等待在途流排空的上限,可为 0 | 否 |
server.vpn.icmp_enabled |
true |
节点级 ICMP echo 总开关,是上限而不是承诺:peer 还要单独开启,且出口 Agent 必须在线并已协商 stream_icmp_echo.v1。开关关闭时连探针都不会被询问,签发返回 409 vpn_agent_capability_missing |
否 |
server.vpn.icmp_timeout |
5s |
单次 echo 应答超时,超时计入 icmp_timeout |
否 |
server.vpn.icmp_max_concurrent |
64 |
节点并发 echo 上限 | 否 |
三个「0 表示不限」的计数(max_peers、max_flows_total、packet_rate_per_peer)沿用 max_concurrent_tunnels 的既有约定。icmp_timeout 与 icmp_max_concurrent 只在 icmp_enabled=true 时校验,因此关掉 ICMP 不会因为遗留的占位取值而无法启动。
ip_pool 与 node_subnet_size 由 internal/vpn 的 ParsePool 直接校验,加载器和分配器共用同一套规则:「能启动」就等价于「真的能分出地址」,启动时读到的报错与首次签发时读到的报错是同一条。每个子网的第一个可用地址保留给节点自己的 VPN 接口,不会分给 peer,所以 /24 子网实际可分配 253 个地址、/30 子网只剩 1 个。
节点自身的 WireGuard 私钥不是配置项,只从环境变量注入:
TUNNELMESH_VPN_NODE_PRIVATE_KEY=<base64 编码的 32 字节私钥>
配置文件会被复制、备份、打进支持包、提交进版本库,而环境变量可以从 Secret Manager 取值且永不落盘,所以私钥只走后者。base64(wg(8) 的写法)与 64 字符 hex(多数 keygen 一行命令的写法)都接受。
该变量由数据面消费,读取时机是启动装配:enabled: true 而变量缺失或不可用会让进程启动失败,原文分别是 vpn: TUNNELMESH_VPN_NODE_PRIVATE_KEY is not set, so the gateway has no wireguard identity 与 vpn: TUNNELMESH_VPN_NODE_PRIVATE_KEY is not a usable wireguard private key(外层还会包一层 server runtime: vpn gateway: )。enabled: false 时根本不读它,所以不使用 VPN 的部署不必注入一个用不上的密钥。
管理面不会因为节点私钥缺失而挂掉:config:reveal 此时返回 409 vpn_node_disabled,操作员仍然能用后台吊销旧密钥签发的 peer。下发给每个 peer 的私钥用既有的 TUNNELMESH_TOKEN_ENCRYPTION_KEY(AES-256-GCM)密封,与凭据密文同构;密钥不可用时签发与 reveal 返回 503 credential_secret_unavailable,不会降级为明文。两类私钥都不会出现在日志、审计与指标里。
节点私钥一旦更换,所有已下发配置立即失效,必须全部重新 reveal 导入。轮换前先评估影响面并准备好重新下发的通道。
命令行与环境变量等价(TUNNELMESH_SERVER_VPN_*):
tunnelmesh-server --server.vpn.enabled=true \
--server.vpn.listen=0.0.0.0:51820 \
--server.vpn.endpoint_host=gw-1.mesh.example.com \
--server.vpn.ip_pool=10.64.0.0/16 \
--server.vpn.node_subnet_size=24
改完先用 tunnelmesh-server check-config 校验再重启。集群里每个节点必须配置相同的 ip_pool 与 node_subnet_size,否则子网租约会互相拒绝;节点通过 vpn_ip_leases 各自抢占一个子网,租约由 epoch fencing 保护,失去租约的节点无法继续从该子网分配地址。
Agent 连接池
Agent 保持一个 server_url,但可以复用同一个逻辑 Agent 身份建立多条物理 WebSocket 连接。默认配置禁用扩容:
agent:
server_url: wss://tunnel.example.com/ws/agent
id: agent-devbox
connections:
min: 1
max: 1
high_watermark: 16
low_watermark: 2
evaluation_interval: 10s
cooldown: 30s
max 大于 1 前必须完成所有入口 Server 的滚动升级。max 的可配置区间是 1–512(config.Validate 拒绝超过 AgentConnectionsCeiling 的值,这个上界是用来抓 “5120” 这类笔误,不是测量出来的性能墙),并且必须同时把 Server 侧 server.agents.max_connections_per_agent 抬到不低于它,否则会持续撞 agent connection capacity reached。多实例和多连接的发布、监控与回滚步骤见逻辑 Agent 连接池运维指南。
脚本执行
管理员可以在管理后台的 Agent 列表上对在线 Agent 运行一段 shell 脚本,并查看输出。默认开启;按钮只出现在实际宣告 agent_exec.v1 的在线 Agent 上。权限、限制与风险见 ADR 0005。
server:
exec:
enabled: true # 关闭后拒绝新的脚本运行,即使 Agent 已宣告能力
agent:
exec:
enabled: true # 关闭后不宣告 agent_exec.v1,也拒绝 exec 流
shell: "" # 空:Unix 为 /bin/sh,Windows 为 powershell.exe
default_timeout: 60s
max_timeout: 10m
max_output_bytes: 1048576 # stdout 与 stderr 合计上限,超出即截断并标记
max_script_bytes: 65536
max_concurrent: 2 # 超过则返回 busy,不排队
- 脚本以 Agent 进程所在账户运行,只继承
PATH、HOME、LANG等少量变量,不继承 Agent 的环境变量(因此读不到 Agent 令牌)。 - 超时与关闭会终止整个进程组。Windows 当前只终止 shell 进程本身,其子进程不会被一并结束;在 Windows 上启用前应确认这一点。
- 审计与 Agent 本地日志只记录脚本的 SHA-256、长度、退出码和耗时,不记录脚本正文和输出。
- 一次性票据只在签发它的 Server 节点上有效,因此多节点部署时,运行脚本的请求需要落到同一节点;管理后台的按钮也只依据本节点看到的在线会话显示。
- 关闭
agent.exec.enabled后需要重启 Agent,之后它不再宣告能力,列表中的按钮自动消失。
Agent 传输:WebSocket、TLS/TCP 与 QUIC
agent.server_url 支持五种 scheme。WebSocket 仍是默认且唯一需要 HTTP 入口的方式;另外两种裸传输绕开 HTTP 栈,适合只放行 TCP 或 UDP 的网络。
| scheme | 传输 | 加密 | 说明 |
|---|---|---|---|
wss:// |
WebSocket over TLS | 是 | 默认 |
ws:// |
WebSocket | 否 | 仅回环,或 agent.transport.allow_insecure_transport: true |
tls://host:port |
TCP + TLS 1.2 起 | 是 | 需要 Server 端开启 server.agent_transports.tcp |
tcp://host:port |
TCP 明文 | 否 | 仅回环,或 allow_insecure_transport: true;令牌明文发送 |
quic://host:port |
QUIC(UDP),TLS 1.3 | 是 | 需要 Server 端开启 server.agent_transports.quic;不能经过 HTTP CONNECT 或 SOCKS5 代理 |
agent:
server_url: quic://tunnel.example.com:7443
transport:
proxy_mode: direct # quic:// 只能是 direct
tls_ca_file: /etc/tunnelmesh/private-ca.pem # 可选:tls:// 与 quic:// 使用的信任根
tls_ca_file 只影响 tls:// 与 quic://,留空则使用系统根证书,与 wss:// 相同。raw 传输的 URL 必须带端口,且不能有路径、查询或用户信息。
Server 端的裸传输默认全部关闭,空的 listen 地址表示该监听不启用,因此升级本版本不会改变现有部署:
server:
agent_transports:
tcp:
listen: 0.0.0.0:7000 # 空表示关闭
tls_cert_file: "" # 留空则复用 tls.cert_file;都没有时只能绑定回环(明文)
tls_key_file: ""
quic:
listen: 0.0.0.0:7443 # 空表示关闭;必须有证书
tls_cert_file: /etc/tunnelmesh/server.crt
tls_key_file: /etc/tunnelmesh/server.key
max_unauth_total: 256 # 未认证连接总数上限
max_unauth_per_ip: 8 # 每个地址的未认证连接上限
preface_timeout: 3s # 连接后必须在此时间内发出认证 preface
- 裸传输的监听地址不能与
server.http_addr的端口相同。 - 连续 10 次认证失败的地址会在一分钟内被拒绝接入,其他地址不受影响。
- 证书只在启动时加载,更换证书需要重启 Server,与 HTTPS 入口一致。
- 防火墙需要放行对应的 TCP 端口(
tcp)或 UDP 端口(quic)。QUIC 无法穿过只转发 TCP 的负载均衡器,需要 L4 UDP 转发。
自动升级与进程守护
client run 与 agent run 默认带有进程守护和自动升级,两者都可以通过配置关闭。
守护进程(supervisor)就是你启动的那个进程。它把真正干活的 worker 作为子进程拉起:worker 崩溃后按指数退避重启;worker 起不来或一直连不上 Server 时,supervisor 以退出码 70 退出,把后续处理交给 systemd、launchd、WinSW 或容器运行时。client.lock、agent.id 回写等行为都发生在 worker 里,和以前一致。
client: # agent.update.* 的结构完全相同
update:
enabled: true # 自动升级与失败回滚;false 时仍保留崩溃重启
supervise: true # 父进程守护;false 时 run 的行为与引入该功能之前完全一致
source: github # github | server
check_interval: 6h # 不小于 5m;实际会随机加上最多 25% 的抖动
health_timeout: 120s # 新版本必须在这段时间内报告就绪(10s–30m)
stable_window: 120s # 就绪后还要稳定运行这么久才确认升级(0–30m)
bad_version_ttl: 24h # 升级失败的版本在这段时间内不再重试
keep_versions: 2 # 保留多少个已下载版本用于回滚
state_dir: "" # 默认是 instance id 文件所在目录
github:
repository: "" # 默认取 downloads.github_repository
mirror_base_url: "" # GitHub 镜像地址,例如 https://mirror.example/github
每个键都有对应的环境变量,例如 TUNNELMESH_CLIENT_UPDATE_ENABLED、TUNNELMESH_AGENT_UPDATE_SUPERVISE。GitHub 令牌只通过环境变量 TUNNELMESH_GITHUB_TOKEN 提供,用于提高 API 限额,不是必需的。
关闭方式
| 目的 | 配置 |
|---|---|
| 只关闭自动升级,保留崩溃重启 | update.enabled: false |
完全关闭,run 与以前一致 |
update.enabled: false 且 update.supervise: false |
enabled: true 且 supervise: false 是非法组合(升级由 supervisor 执行),check-config 会报错,不会被悄悄纠正。
升级到哪个版本
目标版本是“最新 Release”与“Server 版本”中较小的一个:client 和 agent 永远不会比它们连接的 Server 更新,所以升级顺序是先升级 Server。另外:不降级;开发构建(版本为 dev)不参与;稳定版不会升级到 -pre 版本;Server 版本未知(例如 Server 太旧、没有在握手里报告版本)时不升级。
升级与回滚过程
- worker 就绪后,supervisor 按
check_interval检查新版本,下载发行包并用同一个发布里的SHA256SUMS校验。只会解出名为tunnelmesh-<role>的那一个文件。 - 预检:新二进制的
--version必须与发布版本一致,并且check-config必须接受当前配置。失败的版本会被记入坏版本表,不会切换。 - 切换:新版本放在状态目录的
updates/versions/<version>/,不会修改安装目录里的二进制,所以只读安装目录(systemdProtectSystem=strict)和 Windows 上正在运行的 exe 都不是问题。 - 试运行:新版本必须在
health_timeout内就绪,并在就绪后稳定运行stable_window,才算升级成功。期间崩溃、拒绝配置(退出码 78)、一直不就绪,都会自动回滚到升级前的版本,并把该版本记为坏版本bad_version_ttl。 - supervisor 自己在试运行期间被杀掉也没关系:下次启动看到未确认的试运行,会直接回滚。
回滚只处理“升级引起的失败”,不会改写你的配置文件。
退出码
| 退出码 | 含义 | systemd 是否重启 |
|---|---|---|
| 0 | 正常停止 | 是(三个角色都用 Restart=always);systemctl stop 是停止任务,不会重启 |
| 70 | supervisor 放弃(worker 反复崩溃且从未就绪) | 是 |
| 75 | client.lock 被占用(重启时的正常现象,supervisor 会自行重试) |
- |
| 77 | 凭据被永久拒绝 | 否(RestartPreventExitStatus) |
| 78 | 配置错误 | 否(RestartPreventExitStatus) |
退出码 0 不再被当作“进程自愿结束”的证据:会话角色被意外拆掉时,worker 与 supervisor 都可能是 0 退出,
Server 的入口在监听器被意外关掉时同样返回 0。因此三个角色的单元都用 Restart=always,把“拉起”交给
服务管理器而不是退出码,只靠 RestartPreventExitStatus=78 77 挡住重启修不好的两类错误。
这张表同样适用于 tunnelmesh-server:它的入口现在与 client/agent 共用 cli.Main,因此 run 与
check-config 会真正按 78/77 退出(此前 log.Fatal 把它们压成 1),SIGTERM 会先执行优雅停机再以 0 结束。
77 只有 supervisor 会产出,Server 侧不会出现,保留在同一张表里是因为三个角色共享同一份契约。75 是 client
的锁冲突,同样不会出现在 Server 上。
状态目录
<state_dir>/updates/ 下保存 state.json、已下载的版本和运行时临时目录。该目录不可写时服务不会启动失败:supervisor 降级为“只重启、不升级”,并在日志里说明原因。打包的 systemd 单元已经让 /var/lib/tunnelmesh-<role> 可写。
容器
官方镜像把 update.enabled 与 update.supervise 默认设为 false:容器本身由编排器守护,镜像标签也应当是不可变的。需要时用环境变量重新打开。
由 Server 分发(可选)
网络无法访问 GitHub 时,可以让 Server 分发发行包:
server:
updates:
dir: /var/lib/tunnelmesh/releases # 绝对路径;留空(默认)表示关闭
client:
update:
source: server
dir 里放与 GitHub Release 相同布局的文件:SHA256SUMS、manifest.json(至少包含 {"version": "vX.Y.Z"})和 tunnelmesh-vX.Y.Z-<os>-<arch>.tar.gz|zip。client/agent 用自己的服务令牌访问 /api/v1/{client|agent}/updates/...,下载受并发限制,只暴露这一个目录里符合发行命名的文件。
信任边界
SHA256SUMS 与压缩包来自同一个来源,所以它能防止损坏和截断,但不能防止来源本身被篡改。启用 source: server 意味着 Server 被攻破时,所有 client 和 agent 都可能收到恶意二进制,影响面比只走 GitHub 更大;因此分发默认关闭,需要 Server 管理员显式配置 server.updates.dir。决策背景见 ADR 0003。
Stream 延迟与授权缓存
SOCKS5、HTTP 代理和 TCP 转发的打开行为由 capability 协商决定。旧版本链路继续使用 legacy 乐观打开;Client、Server 和 Agent 都支持 stream_open_result.v1 时,Agent 完成目标连接后才返回成功。
Server 默认配置:
server:
stream:
max_concurrent_opens: 256
max_active_per_agent: 1024
max_pending_opens: 1024
initial_window: 262144
window_update_threshold: 131072
max_frame_payload: 32768
authorization_cache:
enabled: true
local_positive_ttl: 5s
cluster_positive_ttl: 5m
negative_ttl: 3s
revision_poll_interval: 2s
max_stale_on_poll_error: 5s
max_entries: 100000
max_concurrent_opens 是处理宽度(同时在进行的目标拨号数),max_active_per_agent 是存量水位(同一个 Agent 此刻保有的活跃流数量)。两者缺一不可:只限并发处理时,一个 Client 可以慢慢打开成千上万条不读的流,把每条流的缓冲与状态永久钉在 Agent 上;0 表示不限,负数被 check-config 拒绝。超限时 Server 直接回 open_result 的 queue_full(stage queue、可重试),Client 退避后重开,不会把 Agent 拖进拨号队列。
initial_window 与 window_update_threshold 是 Agent 数据面的真实额度:前者既是 OPEN_STREAM 通告给 Agent 的 credit,也是 Server 为每条流预留的接收缓冲上限;后者是回补 WINDOW_UPDATE 的累计阈值。二者必须满足 initial_window - window_update_threshold >= max_frame_payload,否则发送方在剩余窗口不足一帧时无处可去,配置校验会直接拒绝。max_frame_payload 只有 32768 一个合法值,因为帧编解码没有协商其它 DATA 尺寸。对端通告的窗口仍会被夹紧,详见 WebSocket 代理协议模块。
authorization_cache 使用数据库中的共享授权修订号失效。SQLite 正向缓存默认 5 秒;MySQL 集群默认 5 分钟,并通过 2 秒修订号轮询保证权限变更尽快生效。轮询失败超过 max_stale_on_poll_error 后缓存 fail-closed,新请求会回源数据库。所有 Token、用户、Agent 和策略变更必须与修订号更新处于同一数据库事务。
本节点创建、轮换或撤销 service token 时会立即通知本地缓存,因此本节点新流通常无需等待轮询周期。其它节点、直接数据库写入和旧版本节点仍依赖 revision 轮询。/ready 中的 authorization_cache 组件表示修订号源是否健康;轮询失败会让该组件变为 unhealthy,并在超过容忍时间后停止使用正向缓存。需要逐请求回源时可将 authorization_cache.enabled 设为 false,该配置只影响缓存,不会降低认证和授权检查强度。
Agent 默认使用有界拨号执行器,单个慢目标不会阻塞同一 WebSocket 上的其它 Stream:
agent:
streams:
max_concurrent_dials: 32
max_active: 1024
max_pending_dials: 128
connect_timeout: 5s
open_timeout: 8s
inbound_buffer_bytes: 262144
# VPN 网关的 ICMP echo 出口,默认关闭。协议见 protocol/proxy-modules.md。
icmp_enabled: false
icmp_bind_address: 0.0.0.0
icmp_timeout: 5s
icmp_max_concurrent: 64
inbound_buffer_bytes 决定每条流最多缓冲多少 Server→Agent 字节,最小值是两个整帧(65536),低于该值会让正常的背压变成 RESET。max_active 是 Agent 自己的存量水位(纵深防御的第二层,语义与 Server 侧 server.stream.max_active_per_agent 相同),超限时 Agent 在创建任何流状态之前直接回 queue_full;0 表示不限。两侧任一层被单独调整都不会破坏协议:这是一个水位,不是协商参数。
| 键 | 默认值 | 含义 | 是否必填 |
|---|---|---|---|
agent.streams.icmp_enabled |
false |
是否打开非特权 ping socket 并通告 stream_icmp_echo.v1。默认关闭,因此升级二进制本身不会在任何主机上开 socket |
否 |
agent.streams.icmp_bind_address |
0.0.0.0 |
ping socket 绑定的地址(不是 host:port),必须是 IP;仅在 icmp_enabled=true 时校验 |
否 |
agent.streams.icmp_timeout |
5s |
单次 echo 等待应答的上限,超时回 timeout 状态而不是错误 |
否 |
agent.streams.icmp_max_concurrent |
64 |
该 Agent 进程的并发 echo 预算,超限立即回 capacity_exhausted,不排队 |
否 |
icmp_enabled=true 还需要主机一次性放开非特权 ping socket 的 gid 范围,否则 socket 打不开、能力不通告,Server 会拒签 ICMP peer:
# 立即生效
sysctl -w net.ipv4.ping_group_range='0 2147483647'
# 重启后仍生效
echo 'net.ipv4.ping_group_range = 0 2147483647' > /etc/sysctl.d/99-tunnelmesh-icmp.conf
sysctl --system
收窄范围(例如只允许 Agent 的运行 gid)也可以,只要包含该进程 gid 即可。Agent 打不开 socket 时不会退出:它在 stderr 记一条 Error 并继续服务 TCP/UDP/HTTP 隧道,因为一个可选能力不该把整个 Agent 拉下线。命令行与环境变量等价:TUNNELMESH_AGENT_STREAMS_ICMP_ENABLED 等。
Client 默认等待严格打开结果并限制入站缓冲:
client:
stream:
open_timeout: 8s
inbound_buffer_bytes: 262144
remote_validation:
positive_ttl: 15s
negative_ttl: 2s
timeout: 3s
max_entries: 10000
remote_validation 只缓存外部校验的 allow/deny 决策,不缓存凭据或目标响应体;同键并发请求会合并。生产环境优先调小 cluster_positive_ttl 和 remote_validation.positive_ttl,在权限收敛速度和数据库/外部校验压力之间取得平衡。
安全白名单与兼容开关
security:
# 管理 API 与 WebSocket 的 Host 精确白名单;空列表表示不限制。不支持通配符。
allowed_hosts:
- tunnel.example.com
# 浏览器 Origin 精确白名单;空列表表示不限制。不支持通配符。
allowed_origins:
- https://tunnel.example.com
# Deprecated: 迁移期开关,计划在 v0.3.0 移除。
allow_legacy_connection_tokens: false
allowed_hosts 按 Host 头精确匹配,动态托管域名由路由表匹配 server.dynamic_suffix,不需要写进白名单。allowed_origins 按规范化后的 Origin 精确匹配;Agent 连接 wss://tunnel.example.com/ws/agent 时发送 https://tunnel.example.com,Client 连接 wss://tunnel.example.com/ws/client 时发送 https://tunnel.example.com,启用 WebSSH/SFTP 时还必须包含管理后台的精确 Origin,否则握手直接被拒绝。
WebSocket 握手始终要求请求带且只带一个合法 Origin 头,与白名单是否为空无关;用 websocat 等工具直连时必须显式指定 -H 'Origin: https://tunnel.example.com'。
allow_legacy_connection_tokens 是唯一的例外开关:Agent 连接默认只接受 agent 类型 service token。设为 true 时旧版管理登录 token 也可用于运维接管,该路径写入弃用审计日志,并且计划在 v0.3.0 移除。仅在有存量 Agent 尚未换发 service token 的迁移窗口内临时开启,迁移完成后必须改回 false。Client 连接不受该开关影响。
管理台身份认证(SSO / MFA / 受信任设备)
管理后台的单点登录、TOTP 两步验证和受信任设备由 security.auth 段配置:
server:
# 反向代理地址白名单;只有来自这些地址的 X-Forwarded-For 才可信。
# 默认空列表:始终使用直连对端地址,伪造头无法影响登录限流和审计。
trusted_proxies: []
security:
auth:
# 管理台 bearer token 有效期;0 表示沿用历史行为(不过期)。
session_token_ttl: 0s
login_throttle:
# 同一 username+IP 桶在 window 内允许失败的次数,超过后封禁 block。
max_attempts: 10
window: 5m
block: 10m
mfa:
# 验证器 App 显示的标签,会写进 otpauth:// URL,不能包含冒号。
issuer: TunnelMesh
digits: 6
period: 30s
# 接受的前后时间步数;0 表示只接受当前步。
skew: 1
# 登录二次验证 challenge 的有效期与最大尝试次数。
challenge_ttl: 5m
max_attempts: 5
# 每次绑定发放的一次性恢复码数量。
recovery_codes: 10
device_trust:
enabled: true
cookie_name: tm_device
cookie_secure: true
# lax / strict / none;none 必须同时开启 cookie_secure。
cookie_same_site: lax
# 未过期的受信任设备是否可以跳过二次验证。
bypass_mfa: true
oidc:
# 是否在登录页公开列出可用的身份提供商按钮。
public_providers: true
http_timeout: 10s
state_ttl: 10m
# 回调后换取会话的一次性 ticket 有效期,硬性上限 300s。
login_ticket_ttl: 60s
jwks_cache_ttl: 1h
# 依赖方所有 HTTP 响应体上限(discovery / JWKS / userinfo / token 共用)。
max_discovery_body_bytes: 1048576
字段参考
| 键 | 类型 | 默认值 | 取值范围 | 含义 |
|---|---|---|---|---|
security.auth.session_token_ttl |
duration | 0s |
≥ 0 | 管理台 bearer token 有效期。0 保留升级前的“不过期”行为 |
security.auth.login_throttle.max_attempts |
int | 10 |
1–1000 | 单个 username+IP 桶在一个 window 内允许的失败次数 |
security.auth.login_throttle.window |
duration | 5m |
1s–24h | 失败计数窗口 |
security.auth.login_throttle.block |
duration | 10m |
1s–24h | 超过阈值后的封禁时长,响应带 Retry-After |
security.auth.mfa.issuer |
string | TunnelMesh |
≤ 64 字符,禁止 : |
otpauth 标签中显示的发行方名称 |
security.auth.mfa.digits |
int | 6 |
6 或 8 |
TOTP 位数 |
security.auth.mfa.period |
duration | 30s |
15s–120s | TOTP 时间步长 |
security.auth.mfa.skew |
int | 1 |
0–2 | 允许的前后时间步数,用于容忍客户端时钟漂移 |
security.auth.mfa.challenge_ttl |
duration | 5m |
30s–30m | 登录二次验证 challenge 的有效期 |
security.auth.mfa.max_attempts |
int | 5 |
1–20 | 单个 challenge 允许的验证次数 |
security.auth.mfa.recovery_codes |
int | 10 |
1–50 | 每次绑定发放的一次性恢复码数量 |
security.auth.device_trust.enabled |
bool | true |
— | 是否允许签发受信任设备(仅作为数据库首启种子) |
security.auth.device_trust.cookie_name |
string | tm_device |
≤ 64 字符,合法 cookie token | 受信任设备 cookie 名称 |
security.auth.device_trust.cookie_secure |
bool | true |
— | cookie 是否只通过 HTTPS 发送 |
security.auth.device_trust.cookie_same_site |
string | lax |
lax/strict/none |
cookie SameSite 属性;none 必须搭配 cookie_secure: true |
security.auth.device_trust.bypass_mfa |
bool | true |
— | 未过期的受信任设备是否跳过二次验证(仅作为首启种子) |
security.auth.oidc.public_providers |
bool | true |
— | 是否公开 GET /api/v1/auth/oidc/providers;关闭后返回 404 |
security.auth.oidc.http_timeout |
duration | 10s |
≤ 5m | 访问 issuer discovery、JWKS、token 和 userinfo 端点的超时 |
security.auth.oidc.state_ttl |
duration | 10m |
≤ 1h | authorization state / nonce / PKCE verifier 的有效期 |
security.auth.oidc.login_ticket_ttl |
duration | 60s |
≤ 300s | 回调后换取会话的一次性 ticket 有效期 |
security.auth.oidc.jwks_cache_ttl |
duration | 1h |
≤ 24h | JWKS 公钥缓存时长 |
security.auth.oidc.max_discovery_body_bytes |
int64 | 1048576 |
1024–16777216 | 依赖方所有 HTTP 响应体的字节上限。实际覆盖 discovery、JWKS、userinfo 与 token 四个端点(共用同一限制),键名里的 discovery 是历史命名;超限直接判为失败,不会截断后继续解析 |
server.trusted_proxies |
[]string | [] |
每项为 IP 或 CIDR | 允许被信任 X-Forwarded-For 的反向代理地址 |
所有叶子键都可用环境变量覆盖:前缀 TUNNELMESH_,点号换成下划线,例如
TUNNELMESH_SECURITY_AUTH_MFA_ISSUER、TUNNELMESH_SECURITY_AUTH_LOGIN_THROTTLE_BLOCK、
TUNNELMESH_SERVER_TRUSTED_PROXIES。这些键没有对应的命令行参数。
未设置的叶子在加载时补齐默认值;已设置但越界的值不会被静默改写,而是由 check-config 直接报错。
security.auth.mfa.skew: 0 是合法取值,表示“只接受当前时间步”,不会被当作未配置。
server.trusted_proxies 只影响管理 API 的客户端 IP 解析(登录限流桶、审计记录、受信任设备的
ip 字段)。它独立于 server.proxy_entry.trusted_proxies(tp-* 代理入口专用,默认
127.0.0.1/32、::1/128),两者不要混用。Server 直接对公网暴露时必须保持
server.trusted_proxies 为空,否则任何客户端都能伪造 X-Forwarded-For 自选限流桶。经过
Nginx 反代时应只填写反代实际来源网段,例如 ["127.0.0.1/32", "::1/128"]。
配置只是种子,数据库才是权威
security.auth 中的策略项只在首次启动时写入 auth_settings 表;此后数据库行是唯一权威来源。
具体来说:
session_token_ttl、device_trust.enabled、device_trust.bypass_mfa会在auth_settings行缺失时作为种子写入一次(mfa_mode固定以disabled起播,保证升级后的部署行为与升级前完全一致)。- 管理员通过
PUT /api/v1/auth/policy或后台“单点登录”页修改策略后,再改配置文件或环境变量都不会生效, 重新部署也不会把数据库里的决定覆盖回去。 - 设备有效期与单账号最大设备数没有配置键,只能由
auth_settings.device_trust_ttl_seconds(1 小时–90 天,默认 30 天)和auth_settings.max_trusted_devices(1–100,默认 10)决定。 - OIDC 提供商的 issuer、client id、client secret、role mapping 全部存在
oidc_providers表, 配置文件里没有任何 per-provider 键。 - TOTP 参数(
issuer、digits、period)在绑定时即固化进 otpauth URL,修改配置只影响之后的新绑定, 不会追溯已绑定账号。
需要临时全量关闭两步验证时,改数据库而不是改配置:把 auth_settings.mfa_mode 置为 disabled
并停用所有 OIDC 提供商即可,无需重新发布。步骤见
Schema 升级与回滚。
身份密钥与 fail-closed
SSO 与 MFA 需要存储可恢复的秘密:OIDC client_secret、TOTP 共享密钥、登录 challenge payload
(state / nonce / PKCE verifier)和 login ticket。它们复用 service token reveal 的同一把密钥:
# base64(RawStd / Std)或 hex,解码后必须是 16、24 或 32 字节;只能由环境变量或 Secret Manager 注入
export TUNNELMESH_TOKEN_ENCRYPTION_KEY='<from your secret manager>'
# 可选;未设置时 key id 固定为 default。轮换密钥时必须同步更换
export TUNNELMESH_TOKEN_ENCRYPTION_KEY_ID='<key id from your secret manager>'
未配置该密钥时 Server 仍能启动,密码登录和已绑定账号的 MFA 校验也照常工作,但任何需要落盘秘密的 操作会 fail-closed:
POST /api/v1/auth/mfa/enroll(MFA 绑定)返回503,data.error=secret_storage_unavailable;POST /api/v1/sso/providers和PUT/PATCH /api/v1/sso/providers/{id}在请求带了非空clientSecret时返回503,data.error=secret_storage_unavailable。只靠 PKCE 的公共客户端 (clientSecret留空)可以创建,因为它没有需要落盘的秘密;- 但任何 OIDC 登录都不可用:authorize 需要创建加密的 state challenge,
ChallengeStore.Create在密钥不可用时直接 fail-closed,所以GET /api/v1/auth/oidc/<provider>/authorize也返回503; - 已存有 client secret 的提供商在密钥缺失或 key id 不匹配时无法解析,同样返回
503。
系统不会退化成明文存储。集群中所有 Server 节点必须配置同一把密钥和同一个 key id,否则一个节点 签发的 challenge 或 ticket 在另一个节点上无法解密。
TUNNELMESH_TRACE_SIGNING_KEY 与身份认证无关,只用于跨节点 traceroute hop 签名链校验;集群所有
Server/relay 节点同样必须一致。两个密钥都不得写入提交的配置文件、日志或工单。
完整的启用流程、后台操作和排障表见单点登录与两步验证。
发行下载源
管理后台“发行管理”页展示的仓库地址来自 downloads.github_repository:
downloads:
# 格式固定为 owner/name,默认 nnworld/TunnelMesh。
github_repository: nnworld/TunnelMesh
该键没有对应的命令行参数,只能通过配置文件或环境变量 TUNNELMESH_DOWNLOADS_GITHUB_REPOSITORY 覆盖。使用 GitHub Enterprise 或内部镜像时改成对应的 owner/name;格式非法(缺少 /、包含多个 /、空 owner 或 name、含空格或 ?#@ 等字符)时 check-config 会失败。发行包命名由发布契约生成,不由该配置决定;Server 只展示下载信息和 SHA256 校验和,不代理 GitHub 凭据,也不缓存发行文件。发布流程见二进制发行。
管理员凭据
首次初始化数据库后,在同一数据库配置下创建管理员:
tunnelmesh-server --config tunnelmesh.yaml admin bootstrap
该命令仅在管理员不存在时创建账号并输出一次随机密码;已有管理员时会失败,不会覆盖现有账号。若凭据丢失,在同一数据库配置下执行:
tunnelmesh-server --config tunnelmesh.yaml admin regenerate-credentials --confirm
旧 Token 会被撤销。不要把命令输出写入共享日志或工单系统。
Service token 配置
Service token 在后台 Tokens 页面创建,明文只返回一次。Agent 使用 agent.token,Client 使用 client.token,均可由环境变量覆盖:
export TUNNELMESH_AGENT_TOKEN='one-time-agent-secret'
export TUNNELMESH_CLIENT_TOKEN='one-time-client-secret'
server_node token 配置在 server.relay.node_token 字段。一个 token 可以服务多个 Server 节点:scope.serverNodeIds 为空表示 fleet token,允许所有启用且未逻辑删除的节点;非空时仅允许列表内节点。token 本身不替代节点身份,epoch 也必须一致。
server.relay.endpoint 可以留空。此时 Server 会读取 server.relay.listen 的端口:如果 listen 是具体 IP,则直接使用该 IP;如果是 0.0.0.0、[::] 或空 host,则选择本机第一个可用的非 loopback、非 link-local 地址,优先 IPv4。推导结果只保存在进程内,不回写 YAML。多网卡、容器 NAT 或跨网段环境应显式配置 endpoint。
relay 证书字段有两种合法状态:
ca/cert/key/server_name全部省略:明文模式,仍校验 token、节点状态和 epoch,但不提供传输加密或证书级节点身份;ca/cert/key/server_name全部配置:mTLS 模式,路径必须为绝对路径,证书 SAN 必须包含本节点node.id和共同的server_name。
部分填写 ca/cert/key 会被拒绝,避免意外降级。证书生成和轮换见 Relay mTLS 证书生成与配置。生产环境不把 token 放进提交的配置文件;print-config 不会输出明文。
Recoverable service-token secrets
Set TUNNELMESH_TOKEN_ENCRYPTION_KEY to a base64 or hexadecimal AES key (16, 24, or 32 bytes) and optionally set TUNNELMESH_TOKEN_ENCRYPTION_KEY_ID. The key must come from an environment-injected secret or secret manager and must not be committed to a file or database. New and rotated service tokens are encrypted with AES-GCM. Existing hash-only tokens cannot be revealed and must be rotated. Secret reveal is administrator-only, audited, requires X-Token-Reveal-Confirm plus Idempotency-Key, and returns Cache-Control: no-store.
The same key and key id also encrypt the SSH credential secrets (password, or private key plus passphrase) used by browser SSH/SFTP auto-authentication; see WebSSH/SFTP 会话. Credential secrets are never exposed by a list or detail API and are decrypted only for the credential owner at session-creation time.
The same key and key id also seal the console identity secrets: OIDC client_secret, the TOTP shared
secret, and every auth_challenges payload (OIDC state/nonce/PKCE verifier and the post-callback login
ticket). Without it, MFA enrollment and OIDC provider creation fail closed with 503 and
data.error=secret_storage_unavailable instead of storing plaintext; see
管理台身份认证.
For multi-server deployments set the same high-entropy TUNNELMESH_TRACE_SIGNING_KEY on every server/relay node so hop signatures can be verified across the cluster. It is never returned in traceroute output.