跳转到内容

参与贡献

源码文件:75 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/Cargo.toml
  • Etemenanki/Cargo.lock
  • Etemenanki/.cargo/config.toml
  • Etemenanki/.github/workflows/build.yml
  • Etemenanki/README.md
  • Etemenanki/concepts/Cargo.toml
  • Etemenanki/environment/Cargo.toml
  • Etemenanki/protocols/Cargo.toml
  • Etemenanki/app/Cargo.toml
  • Etemenanki/concepts/src/buffer.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/environment/src/dial/udp.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/protocols/src/socks/udp_link.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/socks/protocol.rs
  • Etemenanki/protocols/src/socks/handshake.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/server/inbound.rs
  • Etemenanki/protocols/src/hysteria/server/datagrams.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/concepts/tests/runtime.rs
  • Etemenanki/concepts/tests/client.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/unit/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/protocol.rs
  • Etemenanki/protocols/tests/pipeline/wireguard.rs
  • Etemenanki/protocols/tests/unit/wireguard/device.rs
  • Etemenanki/environment/tests/integration/udp.rs
  • Etemenanki/environment/tests/unit/routing.rs
  • Etemenanki/protocols/tests/unit/helpers/address_family.rs
  • Etemenanki/protocols/tests/unit/vless/protocol.rs
  • Etemenanki/protocols/tests/unit/vmess/protocol.rs
  • Etemenanki/protocols/tests/unit/trojan/protocol.rs
  • Etemenanki/protocols/tests/unit/mux/frame.rs
  • Etemenanki/protocols/tests/unit/transports/grpc_framing.rs
  • Etemenanki/protocols/tests/unit/dns/message.rs
  • Etemenanki/protocols/tests/unit/hysteria/protocol.rs
  • Etemenanki/protocols/tests/unit/hysteria/auth.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/datagrams.rs
  • Etemenanki/app/tests/unit/config.rs
  • Etemenanki/app/tests/unit/transport.rs
  • Etemenanki/app/tests/integration/e2e_hysteria_inbound.rs
  • Etemenanki/app/tests/support/mod.rs
  • katana/Cargo.toml
  • katana/Cargo.lock
  • katana/.cargo/config.toml
  • katana/.github/workflows/build.yml
  • katana/.github/workflows/release.yml
  • katana/src/config.rs
  • katana/src/serve.rs
  • katana/src/manager/proxy.rs
  • katana/src/manager/node.rs
  • katana/src/runtime.rs
  • katana/src/api/mod.rs
  • katana/src/api/newv2board.rs
  • katana/src/api/sspanel.rs
  • katana/src/manager/mod.rs
  • katana/src/traffic.rs
  • katana/src/meter.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/meter.rs
  • katana/tests/unit/serve.rs
  • katana/tests/unit/outbound.rs
  • katana/tests/unit/inbound.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/runtime.rs
  • katana/tests/unit/api/newv2board.rs
  • katana/tests/support/mod.rs

本页是所有修改内核或 katana 的人共同遵守的工作约定。它说明一项改动属于哪个仓库、合入前必须通过哪些命令、无论改动内容如何审阅者都要检查什么、版本号如何确定、crate 按什么顺序发布、哪些 git 操作不允许,以及这些文档页面如何与代码保持同步。

规则是稳定的,数字却不是。版本、分支、tag 和依赖解析结果每次发布都会变,所以下面每一步都从仓库中读取它们,而不是从本页读取。本页与仓库不一致时,以仓库为准,本页则需要更新(见保持文档与代码同步)。

代码分布在几个独立的 git 仓库中。它们之间没有上下级关系,也没有跨仓库的 Cargo workspace:所有 git、Cargo、测试和发布命令都在你正在修改的仓库内执行。

你要修改的是 仓库 路径 Package
sans-I/O 核心契约、per-connection 运行时、link、connector(连接器)、共享的网络类型 Etemenanki concepts/ etemenanki-concepts
主机侧拨号器(TCP、UDP、QUIC)、共享的 socket 策略、路由模型 Etemenanki environment/ etemenanki-environment
某个协议、传输层、mux、嗅探、DNS Etemenanki protocols/ etemenanki-protocols
独立代理程序的 TOML 配置、入站和出站的构建、router 组合、generation 与热重载 Etemenanki app/ etemenanki-app
面板客户端、节点生命周期、准入、流量计费、限速、审计、katana 自身的配置和重载 katana src/ katana

有三条边界容易让人踩坑:

  • 路由语义属于 environment/src/routing.rs。 路由模型是从独立的路由 crate(harranu)原样 vendored 过来的,即 etemenanki_environment::routing,app 和 katana 用的都是这份副本。只在 harranu 里做的修改两者都不会生效。harranu 本身需要修改时单独发布,绝不随内核发布;同时要评估同样的修改是否也应落到 environment/。
  • 参考源码树不是实现。 Etemenanki 中的 Xray-core/ 和 hysteria/ 是上游源码(git submodule),用于协议参考,并由两个仓库的互通测试构建;katana 中的 Xboard/、V2bX/ 和 XrayR/ 用于面板参考。与它们对接时发现的 bug 要在 Rust 代码中修复,绝不能靠修改参考源码树。
  • katana 只能看到已发布的内核。 katana 通过版本号从私有 Cargo registry 依赖 etemenanki-concepts、etemenanki-environment 和 etemenanki-protocols。内核的修复只有在相关 crate 发布、且 katana 的 lockfile 切换到这些版本之后才会进入 katana。详见 Workspace 与 crate,其中也列出了 katana 启用的 feature(etemenanki-protocols 上的 vendored-openssl 和 hysteria,不启用 tun)。

被 .gitignore 忽略的文件(临时副本、构建产物)从来不是权威代码。只审阅和修改被跟踪的源码。

  1. 读取仓库的实时状态。 后面的每个决定都依赖它。

    终端窗口
    git status --short --branch
    git branch --show-current
    git remote -v
    git log --oneline --decorate --max-count=12
    git tag --sort=-v:refname | head -20
  2. 读取 manifest。 Cargo.toml、Cargo.lock 和 .cargo/config.toml 说明了有哪些版本、crate 发布到哪个 registry,以及 registry 如何认证。package 依赖图用 cargo metadata --no-deps --format-version 1 查看。依赖实际解析到的版本以 Cargo.lock 或 cargo metadata 为准,而不是 Cargo.toml 中的版本要求。

  3. 读调用链,而不只是函数本身。 修改 concepts、environment 或 protocols 中的公开项时,要在 katana 中搜索它的每一处使用:那里的签名变化就是一次 breaking 发布(见版本规则)。

  4. 走一遍失败路径。 对于配置、协议、网络或生命周期相关的代码,要问清楚:输入畸形时会怎样,对端停止读取时会怎样,取消时会怎样,某个依赖(DNS、端口、面板)不可用时又会怎样。审阅清单列出了以前出过的问题。

修根因,而不是修测试。改动在保证完整的前提下尽量小,不碰无关代码,也不要引入改动用不到的依赖。

改动只有在所属仓库的门槛全部通过后才能合入。发布门槛则在发布或打 tag 之前额外运行。

终端窗口
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
# before a release, additionally:
cargo check --workspace --locked
门槛 保护的是什么
cargo fmt … --check 统一格式,让 diff 只显示真正的改动
cargo test 行为,包括下面清单中点名的测试
cargo clippy … -D warnings 所有警告都视为错误。在 etemenanki-environment 和 etemenanki-protocols 中,这也是 crate 级 unwrap_used、expect_used、indexing_slicing 和 arithmetic_side_effects deny 规则的唯一执行者,它们防止不可信字节让进程 panic;cargo build 和 cargo test 不检查这些规则
--all-features 对没有任何 workspace 成员启用的 feature 背后的代码也做 lint:etemenanki-environment 上的 quic 和 etemenanki-protocols 上的 vendored-openssl。(hysteria 和 tun 本来就会被编译,因为 etemenanki-app 启用了它们。)
--locked 在需要修改 Cargo.lock 时直接失败而不是静默修改,保证测试的就是发布的

两个仓库的 CI 都不运行这些门槛。Etemenanki 的 build.yml 只构建 etemenanki-app 的 release 二进制,katana 的 build.yml 只用 --locked 构建 katana 的 release 二进制,所以 CI 通过并不说明测试或 clippy 通过。请在本地运行这些门槛。

cargo test 通过也可能跳过了一些东西。Xray 和 Hysteria 的互通测试会用 go 从参考源码树构建上游二进制。如果没有 go 或构建失败,支持代码会打印一行 SKIP:,每个需要该二进制的测试会提前返回并判为通过,其中一些会先打印 skipping: hysteria binary unavailable(见 app/tests/support/mod.rs,以及 katana 的 tests/support/mod.rs,后者在同级的 ../Etemenanki checkout 中查找源码树)。修改协议、传输层、TLS、SOCKS 或 WireGuard 时,应在这些互通测试真正运行的情况下验证。测试列出了每个测试套件的运行条件。

请求审阅之前,先检查 diff 本身:

终端窗口
git diff --check
git diff --stat
git diff

留意无关文件、残留的调试输出、临时文件或生成物、变化超出改动所能解释范围的 lockfile,以及任何看起来像凭据、token 或私钥的内容。

这些不变量来自架构设计和以往的审阅,并不是未解决 bug 的列表。每一条都描述了代码必须具备且不能丢失的性质。在已验证的版本中尚未完全满足的条目会明确注明,涉及该区域的改动应向规则靠拢,而不是背离它。审阅时应用所有与被审代码相关的条目,并为改动引入或移动的每个机制补上测试。

UDP 中继只能接受来自它所服务的对端的数据报,客户端只能接受来自它所关联的中继的回复。来自其他任何地址的数据包一律丢弃,绝不中继或交付。

  • 服务端: protocols/src/socks/server.rs 中 SOCKS 服务端的 UDP 关联循环只接收一个客户端(RFC 1928 §7),由一个在中继 socket 绑定之前就构造好的 ExpectedSender 描述。走 TCP 时,客户端就是控制连接的对端:admits 要求每个数据报的 IP 都是该对端的 IP,来自其他 IP 的数据报不经读取就被丢弃。第一个能解析且带有负载的数据报会固定端口(pin;以第一次固定为准),因此同一地址上的邻居无法靠抢先发送垃圾数据来占据这个关联。如果 UDP ASSOCIATE 的 DST.ADDR 写的是对端自己的 IP 且端口非零,该端口会被预先固定。请求中写的其他任何内容(别的地址、未指定地址、域名)既不被信任也不被拒绝,而是直接搁置,因为数据报并不来自那里:NAT 后面的客户端会写它的局域网地址,而 sing-box 只要首个目标是私有地址就会写一个回环地址。回复只发给已固定的客户端,地址形式与中继 socket 看到的一致。走 Unix socket 时没有对端,因此请求必须写明确切的 IP 和非零端口。当中继找不到可以绑定的客户端时,服务端回复 0x02(STATUS_NOT_ALLOWED,“connection not allowed by ruleset”)并关闭控制连接。以下情况会这样处理:Unix socket 上的请求写的是域名,或者地址或端口为零;中继(udp_bind)所在的地址族不包含客户端的 IP。hears 的规则是:IPv4 或 IPv4-mapped 中继只接收 IPv4,未指定的 IPv6 中继 :: 两个地址族都接收,其他 IPv6 中继只接收 IPv6。关联的生命周期不会超过它的控制连接:TCP 流上的 EOF 或错误会结束关联,RELAY_IDLE_TIMEOUT 时间内无流量也会结束关联。FRAG 字节非零的数据报由 protocols/src/socks/protocol.rs 中的 parse_udp_packet 丢弃。

    struct ExpectedSender {
    ip: IpAddr, // 规范形式;每个数据报都必须来自它
    port: Option<u16>, // 请求在 `ip` 之外同时写明端口时
    client: Option<SocketAddr>, // 第一个被转发的发送方;回复发往这里
    }
    impl ExpectedSender {
    fn new(peer: Option<IpAddr>, declared: Option<&Destination>, hub: IpAddr) -> Result<Self, &'static str>;
    fn admits(&self, from: SocketAddr) -> bool;
    fn pin(&mut self, from: SocketAddr);
    fn client(&self) -> Option<SocketAddr>;
    }
    fn hears(hub: IpAddr, ip: IpAddr) -> bool;
  • 比较方式: 两侧都用 protocols/src/socks/protocol.rs 中的 endpoint 比较发送方:取规范形式的 IP(因此双栈 socket 收到的 IPv4-mapped IPv6 地址就等同于对应的 IPv4 地址)和端口,不计 IPv6 的 flow info 和 scope。

    pub(crate) fn endpoint(addr: SocketAddr) -> (IpAddr, u16);
  • 客户端: protocols/src/socks/udp_link.rs 中的 SocksUdpLink 会跳过来源不是服务端所告知的中继地址的数据报,判断条件是 endpoint(from) != endpoint(self.relay),因此双栈的客户端 socket 仍能接收 IPv4 中继的数据报。它自己的 UDP ASSOCIATE 不写来源(全零,这是 RFC 1928 规定客户端在不知道来源时的做法),所以会检查来源的服务端会把它限定为控制连接的地址加上它第一个数据报的端口:

    impl<S> DatagramLink for SocksUdpLink<S>
    where
    S: AsyncRead + AsyncWrite + Unpin,
    {
    type Addr = Destination;
    fn poll_recv_from(
    &mut self,
    cx: &mut Context<'_>,
    buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<Destination>>;
    }

修改任何一侧都需要一个负向测试:从第三方地址发送,并断言没有任何内容被交付。现有的负向测试是 protocols/tests/pipeline/socks.rs 中的 udp_association_ignores_another_ip、udp_association_ignores_another_port_once_pinned、udp_association_is_not_widened_by_the_request 和 udp_link_ignores_datagrams_not_from_the_relay(其中从 127.0.0.2 发送的两个只在 Linux 上运行)。同一文件用 udp_association_over_a_unix_socket_needs_its_exact_source 和 udp_association_refuses_a_relay_that_cannot_hear_the_client 固定了拒绝行为,正向的往返测试是 new_server_vs_new_client_udp。protocols/tests/unit/socks/server.rs 中的单元测试直接固定 ExpectedSender,并通过 ExpectedSender::new 覆盖 hears 的规则;endpoint_sees_through_ipv4_mapping_and_ignores_flow_info(protocols/tests/unit/socks/protocol.rs)固定了比较方式。SOCKS 完整描述了 UDP 关联。

每个 per-connection 缓冲区或队列都有固定上限。为了让生产者继续运行而把 bounded channel 中的数据搬进无界集合,就等于绕过了上限:消费者阻塞时,阻塞必须一路传回生产者。

  • 机制: ProxyServerRuntime 为每条连接分配且只分配一次三个 BUF_SIZE 字节的缓冲区,之后从不扩容:传输层读缓冲区(ReadBuffer<const N: usize>)、传输层暂存缓冲区(WriteBuffer<const N: usize>,两者都在 concepts/src/buffer.rs 中),以及一个出站 scratch 数组。concepts/src/runtime.rs 中的契约是:无法完成的转发会挡住它后面的所有 effect;在一次传输层字节的转发等待期间,不读取传输层;暂存缓冲区的空闲空间不足 STAGING_RESERVE 加一字节时(数据报出站还要再加 MAX_DATAGRAM),不读取出站。因此客户端停止读取时,下行会在出站 socket 处停下。

  • WireGuard 驱动: protocols/src/wireguard/device.rs 中的驱动任务在 bounded channel 与 smoltcp socket 之间搬运每条连接的字节,所以除了运行时的缓冲区之外,它还自己保存 per-connection 状态。每条连接的上行是一个 Uplink:应用的 channel(上限 CHANNEL_CAP = 256 项),加上一个槽位,最多存放一项 socket 尚未接收的数据。只有槽位为空时驱动才读取 channel(持有数据期间 poll_refill 一直返回 Pending),因此当远端或隧道不再消费某条连接时,先是该连接的 socket 被填满(TCP socket 每个方向缓冲 TCP_BUFFER = 64 * 1024 字节),然后是它的 channel,最后阻塞应用的发送端,而其他连接照常流动。驱动每轮最多交给一个 socket CHANNEL_CAP 项,所以没有哪个生产者能独占驱动。下行方向上,只有 socket 对应的 channel 还有空间时才读取该 socket。UDP 数据报遇到所属关联的发送环已满时,会被放进同一个槽位,直到下一次 poll 清空发送环;永远无法发送的数据报(比整个发送环还大,或目标端点无法寻址)会被丢弃,而不是反复重试。

    struct Uplink<T> {
    rx: Option<mpsc::Receiver<T>>,
    held: Option<T>,
    }
    impl<T> Uplink<T> {
    fn take(&mut self) -> Option<T>;
    fn hold(&mut self, item: T);
    fn poll_refill(&mut self, cx: &mut Context<'_>) -> Poll<()>;
    }
  • 测试: stalled_outbound_holds_uplink_but_not_other_downlink、frame_larger_than_the_buffer_is_an_error 和 datagram_outbound_is_never_truncated_by_staging_backpressure(concepts/tests/runtime.rs);backpressure_from_the_wire_reaches_the_writer(concepts/tests/client.rs);a_stalled_tcp_flow_blocks_its_writer(protocols/tests/pipeline/wireguard.rs),检查远端什么都不读的流在上述缓冲区填满后会阻塞其写入端,并且不会拖住同一隧道上的另一条流;a_held_item_keeps_the_channel_unread(protocols/tests/unit/wireguard/device.rs)。

随对端输入增长的会话表同样有明确上限,例如 Hysteria 2 UDP 会话(protocols/src/hysteria/server/datagrams.rs)和 mux 子流(protocols/src/mux/demux.rs)每条连接的 MAX_SESSIONS = 256。

声称限制活跃连接数的 semaphore 必须持有许可(permit)直到中继结束。只覆盖握手、解码或传输层阶段的许可限制的是那个阶段,而不是活跃连接数。在每个 spawn 处都要确认许可被移入了 spawn 出的任务。

位置 常量 持有者
App 的流式入站(app/src/serve.rs) MAX_LIVE_CONNECTIONS_PER_INBOUND = 65_536 在 run_stream_inbound 中获取的 Arc<OwnedSemaphorePermit>,被 clone 进传输层产出的每个流,并在整个 serve_connection 期间绑定为 _session
katana 的流式节点(src/serve.rs、src/manager/proxy.rs) MAX_LIVE_CONNECTIONS_PER_NODE = 65_536 同样的模式:ProxyManager::accept_stream 把会话许可移入与 serve_stream 一起 spawn 的任务
Hysteria 2 circuit(protocols/src/hysteria/server/inbound.rs、datagrams.rs) circuit_permits:在 app 中是入站的 max_circuits,默认 DEFAULT_MAX_CIRCUITS = 65_536;在 katana 中是 MAX_LIVE_CONNECTIONS_PER_NODE 每个代理流一个许可,在等待运行时的任务中绑定为 _permit;每个 UDP 会话一个许可,由会话条目持有

与它们并列的阶段限制(app 中的 MAX_HANDSHAKES_PER_INBOUND = 2048,katana 中的 MAX_TRANSPORT_STAGES_PER_NODE = 2048 和 MAX_PREAUTH_STREAMS_PER_NODE = 512)是有意分开的,只覆盖握手阶段,不是连接数限制。the_circuit_limit_refuses_new_sessions(protocols/tests/unit/hysteria/server/datagrams.rs)固定了 UDP 会话的 circuit 上限。

当一个域名解析出多个地址时,调用方必须选出本地 socket 真正能到达的那个,并把其余地址留作备选,而不是拿第一个结果、在该地址族不可用时直接丢包。

  • 机制: protocols/src/helpers/address_family.rs 中的 select_candidate_ips 按出站的 AddressFamilyStrategy 和 FamilySupport(出站能以哪些地址族作为源地址发出流量:把源地址选择交给内核的拨号器用 FamilySupport::both(),有自己接口地址的用户态网络栈用 FamilySupport::from_addrs)过滤解析结果,并用稳定排序排列,让另一个地址族保留为备选。environment/src/dial/udp.rs 中的 DualStackUdp 为每个请求的地址族绑定一个 socket,绑定失败的地址族直接略过,并用与对端地址族相同的 socket 发送每个数据报。

    pub fn select_candidate_ips(
    resolved: Vec<IpAddr>,
    strategy: AddressFamilyStrategy,
    support: FamilySupport,
    ) -> Vec<IpAddr>;
    impl DualStackUdp {
    pub fn socket_for(&self, peer: &SocketAddr) -> io::Result<&UdpSocket>;
    }
  • 测试: auto_skips_families_without_a_local_address、prefer_ipv4_keeps_ipv6_as_fallback 和 ipv4_only_filters_to_ipv4(protocols/tests/unit/helpers/address_family.rs);v6_socket_is_v6_only_and_dual_stack_picks_by_family(environment/tests/integration/udp.rs)。

解析器检查每一个固定字段(版本、保留字节、长度、命令和类型码),遇到不认识的值就拒绝。不要假设对端是正确的实现,也不要让声明的长度在检查之前就分配或读取超出上限的内容。在 environment 和 protocols 中,上面提到的 clippy deny 规则让未检查的索引和未检查的算术成为 lint 错误,所以代码必须处理越界索引或溢出的长度,而不是因此 panic。

解析器 固定其严格性的测试
VLESS rejects_bad_version、rejects_nonempty_addons、response_header_rejects_version(protocols/tests/unit/vless/protocol.rs)
VMess rejects_unknown_version、rejects_unknown_command、rejects_unknown_security、rejects_invalid_option_relationship(protocols/tests/unit/vmess/protocol.rs)
Trojan rejects_missing_crlf、rejects_oversize_udp_packet(protocols/tests/unit/trojan/protocol.rs)
mux.cool rejects_unknown_status_and_network、rejects_truncated_metadata(protocols/tests/unit/mux/frame.rs)
gRPC 分帧 decoder_rejects_unexpected_field、decoder_rejects_an_oversized_length_before_buffering_the_body(protocols/tests/unit/transports/grpc_framing.rs)
DNS truncated_and_malformed_input_never_panics、rejects_an_over_long_name_and_label(protocols/tests/unit/dns/message.rs)
Hysteria 2 varint_above_62_bits_is_refused_not_panicked、tcp_request_rejects_a_foreign_frame_type(protocols/tests/unit/hysteria/protocol.rs)

拼写错误必须让程序停下,而不是退回到一个改变了配置含义的默认值。稳定的配置结构体拒绝未知字段,无法识别的值是错误,而不是被当作“其他情况”处理。

  • 机制: 配置结构体都带有 #[serde(deny_unknown_fields)](app/src/config.rs 中有 33 个,katana 的 src/config.rs 中有 12 个)。app/src/transport.rs 中的 tls_layer 根据 network 校验 security,而不是拿它和 "tls" 比较,所以未知值会被拒绝,而不会变成明文:

    pub fn tls_layer(network: &str, security: Option<&str>, ctx: &str) -> io::Result<bool>;
  • 测试: a_mistyped_key_is_rejected_rather_than_ignored 和 an_inbound_defaults_to_loopback(app/tests/unit/config.rs);an_unknown_security_is_rejected_on_every_network 和 tcp_with_tls_is_rejected_and_names_the_fix(app/tests/unit/transport.rs);katana 中的 reject_unknown_sni_is_refused_rather_than_ignored、acme_cert_mode_rejected 和 hysteria_refuses_an_unknown_obfs_rather_than_disabling_it(tests/unit/inbound.rs),以及 an_invalid_address_family_is_rejected_for_every_protocol(tests/unit/outbound.rs)。

二进制程序会直接体现这一点。[inbound.stream] 下有一个拼错的键时(已去掉日志时间戳):

$ etemenanki-app --test -c typo.toml
ERROR etemenanki_app: configuration invalid: TOML parse error at line 7, column 1
|
7 | securty = "tls"
| ^^^^^^^
unknown field `securty`, expected one of `network`, `security`, `tls`, `ws`, `grpc`

进程以状态码 1 退出,不绑定任何端口。

尤其要注意决定暴露面或安全性的键:监听地址、端口、协议、network、security、TLS 设置、出站和路由。

重载不能先拆掉正在运行的配置,然后才发现新配置无法启动。先解析、校验和构建;只有成功后才切换;失败时保留旧状态。失败的重载还必须在外部原因(端口被占用、文件缺失)消除后能够重试,而不是只有文件内容变化时才重试。

flowchart LR
  read["读取文件"] --> parse["parse_bytes"]
  parse -->|出错| keep["保留旧 generation"]
  parse --> build["build:router、入站、出站"]
  build -->|出错| keep
  build --> cancel["取消旧 generation"]
  cancel --> spawn["spawn_generation(built, false)"]
  • App 的机制: app/src/instance.rs 中的 Instance::reload 在触碰正在运行的 generation 之前先运行 config::parse_bytes 和 build,失败时记录 reload: parse failed, keeping current config 或 reload: build failed, keeping current config。之后它才取消旧 generation 的 token,等待其 accept 循环释放监听器,并调用 spawn_generation(built, false);在这一步,单个入站绑定失败只会被记录,其他入站照常启动。切换会断开旧 generation 的所有进行中的连接。

    pub fn build(cfg: &Config) -> io::Result<Built>;
    async fn spawn_generation(built: Built, strict: bool) -> io::Result<Generation>;
    impl Instance {
    pub async fn reload(&self);
    }
  • App 在已验证版本中的不足: 新监听器要等旧 generation 释放端口后才绑定,所以重载时绑定失败的入站会一直处于停止状态,而此时旧 generation 已经不在了。另外,当文件内容与上次尝试的内容相同时(解析或构建失败也会记录这份内容),Instance::reload 会提前返回,所以外部原因修复后,未改动的文件不会被再次尝试。要重试,需修改文件或重启进程。对这条路径的改动应当弥补这些缺口,而不是扩大它们。

  • katana 的机制: 无法加载的文件首先在 watcher 循环中被拒绝,并记录 config reload failed, keeping current。随后 src/runtime.rs 中的 apply_reload 在触碰任何东西之前,先构建重载所需的一切:[[outbound]] 变化时的新出站池,重载新增的每个节点(它的面板客户端和 router;此时尚未绑定或 spawn 任何东西),以及条目有变化的每个运行中节点的面板客户端。其中任何一项失败,它都什么都不应用,并记录 reload: bad outbounds, keeping current config 或 reload: node <display_id>: <error>; keeping current config。之后才逐个节点应用节点变化:被删除或身份改变的节点会被停止,新节点用已经构建好的内容 spawn,有变化的节点则收到这次修改。运行中的节点要么完整接受修改,要么完全不接受:无法编译的路由或无法构建的面板客户端会被拒绝,并记录 node <id>: config edit refused, keeping the running one: <error>。重载新增或重新 spawn 的节点如果未能启动(例如端口仍被占用),会按节点任务有监管所述持续重试,所以外部原因消除后它就能恢复;监听器重建失败的运行中节点则在每次轮询时再试一次。

  • 测试: a_reload_rebinds_the_udp_port(app/tests/integration/e2e_hysteria_inbound.rs)检查在旧 generation 持有一条活跃连接时,Hysteria 2 入站在重载后能重新拿回它的 UDP 端口;a_reload_with_a_node_that_does_not_build_changes_nothing(katana tests/unit/runtime.rs)检查 node_type 拼错的条目不会影响运行中的节点、它的活跃连接以及同时做出的路由修改,该文件中的其他测试则固定了哪些修改会重新 spawn 节点、哪些会就地生效;route_change_drops_connections 和 repeated_user_refreshes_never_disturb_a_live_connection(katana tests/unit/e2e.rs)固定了 katana 重载和面板刷新会保留什么。Generation 与热重载和进程运行时与重载完整描述了这两条路径。

首次面板请求、DNS、绑定或启动过程中的瞬时失败,不能让节点任务永久结束且无人重启。长期运行的节点任务需要在监管者(supervisor)之下带退避地重试,同时在取消时仍能及时停止。

  • 启动阶段: src/manager/node.rs 中的 NodeManager::bootstrap 反复调用 try_bootstrap,直到节点启动成功或它的 CancellationToken 被取消。以下情况算一次尝试失败:node_info 失败、没有返回内容或报告端口为 0;user_list 失败或没有返回内容(没有用户的节点即使启动也服务不了任何人);bring_up 失败,例如端口仍被占用。每次尝试前都会先清空面板客户端的 ETag,这样即使面板对重复请求回复 304 Not Modified,也仍会给出完整的应答。失败后节点记录 node <id>: <reason>; retrying in <n>s,然后等待 BOOTSTRAP_RETRY_MIN(1 秒),每失败一次翻倍,最多到 BOOTSTRAP_RETRY_MAX(60 秒),如果轮询周期(controller.update_periodic)更短则以它为上限。等待期间它会随到随应用静态更新,这样运行时发给它的 bounded channel 不会被填满;配置修改会立即结束等待,因为这次修改可能正是修复。无论是在尝试过程中还是在等待期间,取消都会被响应。

    const BOOTSTRAP_RETRY_MIN: Duration = Duration::from_secs(1);
    const BOOTSTRAP_RETRY_MAX: Duration = Duration::from_secs(60);
    impl NodeManager {
    pub async fn run(self: Arc<Self>, shutdown: CancellationToken, static_rx: mpsc::Receiver<StaticUpdate>);
    async fn bootstrap(&self, shutdown: &CancellationToken, static_rx: &mut mpsc::Receiver<StaticUpdate>) -> bool;
    async fn try_bootstrap(&self) -> Result<(), String>;
    }
  • 稳态: 面板请求失败、没有返回内容或报告端口为 0 时,NodeManager::poll_cycle 会退回到上次应用的节点信息;用户列表请求失败或没有返回内容时,退回到上次应用的用户列表。它会记录失败的请求或为 0 的端口,并在下一个 tick 重试。只有当它的 CancellationToken 被取消时循环才会退出。无论节点是在稳态中还是仍在启动阶段被停止,run 随后都会用 tear_down 停止监听器,并刷出最后的流量和审计上报。

  • 测试: a_node_comes_up_once_the_panel_answers、a_node_whose_port_is_taken_comes_up_once_it_is_free(对接一个对重复请求回复 304 的面板)和 a_node_that_never_bootstraps_still_stops(tests/unit/e2e.rs)。节点管理器描述了完整的循环。

重载会重建所有缓存了配置的对象

Section titled “重载会重建所有缓存了配置的对象”

如果某个对象在构建时缓存了配置字段,那么改变这些字段的重载就必须重建或更新该对象。更新了 manager 持有的配置副本、而客户端还在用旧配置,正是这条规则要防止的问题。

  • 身份: 在 src/runtime.rs 中,节点的身份是决定它服务哪个面板节点的那组字段构成的元组。改变身份的重载会删除该节点并 spawn 一个新节点,新节点带有新的 PanelClient 和全新的流量登记表,所以一个面板节点的计数器绝不会计到另一个面板节点上;不改变身份的重载则向正在运行的节点发送 StaticUpdate::Config。

    type NodeId = (String, String, u32, String, String);
    fn identity(cfg: &NodeConfig) -> NodeId;
    pub fn panel_node_type(cfg: &NodeConfig) -> String;

    这个元组是 (panel_type, api.host, api.node_id, api.key, 面板节点类型),其中 panel_type 转为小写。面板节点类型来自 src/api/mod.rs 中的 panel_node_type:newV2board 按节点 id 和请求的 node_type 查找节点,所以对于启用了 enable_vless 的 V2ray 系节点(node_type 为 V2ray、Vmess 或 Vless)它是 "vless",否则是转为小写的 api.node_type;SSPanel 只按 id 查找节点,所以它为空。节点类型不为空时,display_id 会把它显示在 id 之后。

  • 缓存的字段: src/api/newv2board.rs 和 src/api/sspanel.rs 中的 Client::new 会把它需要的每个字段从 panel_type 和 [node.api] 复制进客户端,包括超时、api.node_type、api.enable_vless、api.speed_limit 和 api.rule_list_path。因此只要 panel_type 或 [node.api] 中的任何内容发生变化,src/manager/node.rs 中的 NodeManager::apply_static 就会在保存这次修改之前构建一个新的 PanelClient。新客户端会接管旧客户端从 newV2board 节点配置中读到的路由(PanelClient::inherit_routes),所以由这些路由得出的审计规则会继续生效,直到新客户端自己读取配置。新客户端不持有 ETag,所以节点随后会用它做一次完整的面板轮询,并按应答所需进行范围最小的拆除。api.speed_limit 或 api.rule_list_path 这样的修改会保留所有连接,除非面板的新应答改变了协议或传输层;而修改监听器所依据的本地设置(controller.listen_ip、controller.cert、controller.disable_sniffing、api.enable_vless、[node.hysteria] 或 [node.route])会强制产生新的监听器 generation。

  • 要检查什么: 客户端缓存的新字段只有位于 [node.api] 或 panel_type 之内才算被覆盖;否则它必须加入 apply_static 中的那次比较,或者在重载时推送给客户端。决定服务哪个面板节点的字段属于身份。

  • 测试: an_sspanel_node_is_its_panel_node_id、a_newv2board_node_is_also_the_type_it_asks_for、a_node_is_logged_by_its_panel_node_not_its_key、a_newv2board_type_edit_respawns_the_node、an_sspanel_api_edit_takes_effect_in_place 和 a_client_edit_takes_effect_without_dropping_connections(tests/unit/runtime.rs);a_rebuilt_client_keeps_the_routes_it_has_not_read(tests/unit/api/newv2board.rs)。

令牌桶按实际字节数扣费,并按比例产生等待或欠额。大于桶容量的数据块不能在一个补充周期后就放行,实际达到的速率也不能依赖中继的缓冲区或数据块大小。

  • 机制: src/traffic.rs 中的 TokenBucket 以每秒 rate 字节补充,最多积攒一秒的量(rate 为 0 表示不限速)。charge 总是扣除全部数量,允许余额变为负数,并返回欠额还清的时刻;src/meter.rs 中的 Gate 在每次传输完成后扣费,并在共享的桶处于欠额期间,阻住该用户所有流的两个方向。

    impl TokenBucket {
    pub fn new(rate: u64) -> Self;
    pub fn charge(&self, n: usize) -> Option<Instant>;
    pub fn ready_at(&self) -> Option<Instant>;
    }
    pub fn determine_rate(node_bps: u64, user_bps: u64) -> u64;
  • 测试: token_bucket_rate_limits、a_chunk_larger_than_the_burst_is_still_limited、debt_holds_back_the_next_charge_too 和 an_idle_bucket_banks_one_second_and_no_more(tests/unit/traffic.rs);the_limit_holds_however_the_writes_are_sized 和 the_limit_is_shared_by_both_directions(tests/unit/meter.rs)。除 token_bucket_rate_limits 使用真实时钟并带有容差外,其余测试都运行在暂停的 tokio 时钟上,因此时间是精确的。

如果要限制已认证的活跃连接数(按节点或按用户),就需要像上面的连接数限制一样覆盖整个中继的准入控制;预认证限制只覆盖握手阶段。见准入与用户表。

绝不记录凭据、UUID、token、密钥、面板密钥,或 query 中带有这些内容的 URL。凭据格式错误时,只记录安全的标识和错误类型,绝不记录原值。

  • 机制: src/api/mod.rs 中的 error_for_status 替代了 reqwest 自带的状态检查并去掉 URL,因为面板密钥是放在 query string 中传输的。src/runtime.rs 中的 display_id 把节点显示为面板类型、主机、节点 id,在 newV2board 上还有面板节点类型,绝不包含 api.key。NodeManager::try_bootstrap 只用最外层的错误信息来呈现失败的面板请求,因为其下的原因带有请求 URL,而 URL 的 query 中带有密钥。出站构建器拒绝格式错误的 UUID 时不会引用它。

    pub fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response>;
    fn display_id(id: &NodeId) -> String;
  • 测试: a_malformed_uuid_is_refused_without_echoing_it(katana tests/unit/outbound.rs);a_node_is_logged_by_its_panel_node_not_its_key(katana tests/unit/runtime.rs);an_unencodable_password_is_refused_without_quoting_it(Etemenanki protocols/tests/unit/hysteria/auth.rs)。

修改面板客户端的 HTTP 错误处理时,必须保证它可能返回的每个错误中都不含 URL 和 token。

对流量代码的每次改动都必须保住以下五条性质:

性质 机制(src/traffic.rs、src/manager/node.rs) 测试(tests/unit/traffic.rs)
上报失败不丢数据 只有上报成功后才扣减活跃计数器;失败时残余行通过 restore_residuals 放回 restored_residuals_are_retried
重试不重复计费 commit_reported 只减去实际上报的量;残余行或已完全排空的行在被 snapshot 时离开表 rate_change_drains_old_counter_and_reports_once
残余可恢复 已离开用户的字节会变成按 uid 索引的残余行 dropped_user_bytes_become_residuals、a_departed_users_late_bytes_are_still_reported
删除和重新添加的语义明确 被删除的用户离开表;凭据重新绑定到另一个 uid 时获得新的计数器,旧 uid 的字节仍以旧 uid 上报 set_users_drops_absent、rebound_credential_reports_the_old_uid_separately
并发增量不丢失 commit_reported 使用 fetch_sub,而不是清零 commit_reported_preserves_concurrent
sequenceDiagram
  participant N as NodeManager
  participant T as NodeTraffic
  participant P as Panel
  N->>T: snapshot()
  T-->>N: 活跃行、排空中的行、残余行
  N->>P: report_user_traffic(按 uid 合并的非零行)
  alt 上报成功
    N->>T: 对每个带计数器的行调用 commit_reported(up, down)
  else 上报失败
    N->>T: restore_residuals(没有计数器的行)
  end
impl UserCounter {
pub fn commit_reported(&self, up: u64, down: u64);
}
impl NodeTraffic {
pub fn snapshot(&self) -> Vec<TrafficSnapshot>;
pub fn restore_residuals(&self, rows: Vec<(i64, u64, u64)>);
}

在端到端层面,tests/unit/e2e.rs 中有两个测试让节点对接一个假面板运行:vmess_traffic_is_metered_and_reported 检查面板收到的总量,a_retired_user_stops_while_the_rest_keep_their_connections 检查删除一个用户会停止该用户的连接,而其他用户的连接继续运行。

environment/src/routing.rs 中的路由模型保持 first-match 语义:第一条匹配的规则胜出,没有任何规则匹配的目标走默认出站。对匹配条件或 geodata 加载的修改,审阅时要重点检查大小写规范化、正则校验、CIDR 前缀校验、畸形 protobuf 输入和规则顺序。下界大于上界的端口范围会被 parse_port_match 拒绝,而不是编译成一条永远不会匹配的规则:

pub fn parse_port_match(s: &str) -> io::Result<RouteMatch>;

port_parser_rejects_an_inverted_range 和 port_ranges_are_inclusive(environment/tests/unit/routing.rs)固定了这一点。同样的审阅要求也适用于 harranu,它携带的是同一个模型。

每个发布的 crate 都遵循 Cargo 所理解的 SemVer。Cargo 把 version = "2.0.0" 这样的裸版本要求理解为 caret 要求,而“兼容”的含义取决于版本是否低于 1.0:

版本系列 要求 ^X.Y.Z 接受的范围 Breaking 变更 兼容的新增 兼容的修复
0.y.z 且 y >= 1(1.0 之前) >=0.y.z, <0.(y+1).0 升 y 升 z 升 z
x.y.z 且 x >= 1 >=x.y.z, <(x+1).0.0 升 x 升 y 升 z

etemenanki-concepts、etemenanki-protocols 和 etemenanki-app 曾各自按自己的节奏走过 0.y 系列,直到一起发布为 1.0.0。四个内核 crate 随后一起升到 2.0.0(etemenanki-environment 首次发布就是这个版本号),这是一次主版本发布,因为基于任务和 channel 的旧流水线被直接删除,而不是与新流水线并存。在已验证版本中,etemenanki-protocols 是 2.0.2(在 2.0.1 这次 patch 之后),其余三个仍是 2.0.0。katana 是 3.0.1:它在切换到内核 1.0.0 时升了一次主版本(katana 2.0.0),切换到内核 2.0.0 时又升了一次(katana 3.0.0);3.0.1 是一次 patch 发布,修复了节点引导和重载,并把 lockfile 切换到 etemenanki-protocols 2.0.1,而 Cargo.toml 仍保持 2.0.0 的版本要求。

什么算 breaking:

  • 库 crate: 删除或重命名公开项,修改公开签名、trait 的必需项或公开类型的字段,或者改变某个 feature 的含义。新增公开项或可选 feature 属于兼容的新增。
  • 行为: 现有配置的含义、线上传输的内容或调用方可观察到的行为发生变化时,即使所有签名都没变,也按 API 变更来判断。对 etemenanki-app 和 katana 而言,删除某个配置键或改变其含义属于 breaking 变更。

etemenanki-protocols 2.0.1 是一个有记录的例外。它用 feed_chunks 替换了 Demux::feed_whole;etemenanki_protocols::mux 和 Demux 都是公开的,所以按上面的规则,这次删除属于 breaking 变更。这次发布之所以作为 patch 发出,是因为 crate 之外没有任何地方调用这个方法,release commit 的正文也写明了这一点。这个判断并不改变规则:删除公开项的 patch 需要同样的检查(在 katana 和其他所有依赖方中搜索用法)和同样的书面理由;只要有任何依赖方可能用到该项,这次发布就是 breaking。

etemenanki-protocols 2.0.2 同样作为 patch 发布,公开 API 没有变化:UDP ASSOCIATE 中写明的来源通过 protocols/src/socks/handshake.rs 中 crate 私有的 handshake_with_udp_source 传给 SOCKS 入站,公开的 handshake 保持原有签名。release commit 的正文记录了随之改变的行为:经 Unix socket 连接、请求中没有写明确切来源的客户端现在会被拒绝;中继永远无法接收其客户端数据报的关联也会被拒绝。

如果无法判断某个改动是否兼容,就在审阅中说明,并在发布任何东西之前定下来。只 bump 有变化的 crate;代码和依赖要求都没变的 crate 保持原版本。

发布是一个带有外部影响的有意行为(上传到 registry、推送 commit、打 tag 以及由 tag 构建的二进制),因此只有在确实决定发布时才进行,绝不作为合入改动的副作用发生。

crate 按依赖顺序发布,katana 只有在它所需的内核 crate 在 registry 中可见后才跟进:

flowchart LR
  c["etemenanki-concepts"] --> e["etemenanki-environment"]
  e --> p["etemenanki-protocols"]
  p --> a["etemenanki-app"]
  p --> k["katana:更新 lockfile、bump、打 tag vX.Y.Z"]
  e --> k
  c --> k

跳过所有没有 bump 的 crate。下层 crate 变化时,检查它上面的每个 crate:

变化的 crate 检查以下 manifest 同时检查
etemenanki-concepts environment/Cargo.toml、protocols/Cargo.toml、app/Cargo.toml 各自是否需要修改源码
etemenanki-environment protocols/Cargo.toml、app/Cargo.toml 各自是否需要修改源码
etemenanki-protocols app/Cargo.toml app 是否需要修改源码

crate 之间的依赖写成带 version 和 registry 的 path 依赖,所以发布后的 manifest 指向 registry 中的版本。如果某个 crate 需要只有更新的下层 crate 才提供的东西,就把它的版本要求提高到那个版本。无论哪种情况,都要在 Cargo.lock 中确认 workspace 解析到的正是即将发布的版本。

  1. Preflight。 按开始之前所述读取 Etemenanki 的实时状态(如果 katana 随后跟进,也读取 katana 的),并读取 .cargo/config.toml 了解 registry 及其凭据提供方式。registry token 从不放在仓库中,它来自环境变量或 Cargo 的凭据存储。如果需要先同步分支,只能用 fast-forward 方式 pull(见 Git 规则),无法 fast-forward 时停止。

  2. 确定范围。 用 git status --short、git diff --name-only 和 git diff --cached --name-only 列出实际变化,并把路径对应到 package:concepts/**、environment/**、protocols/**、app/**。根 manifest、feature 和 lockfile 的变化按它们影响哪些 crate 来判断。

  3. 选定版本,依据版本规则,只修改被 bump 的 crate 的 version,以及其他 workspace crate 对它们声明的、必须提高的 version 要求。workspace 成员自己在 Cargo.lock 中的条目会在下一次不带 --locked 运行的 Cargo 命令时跟着更新(门槛 cargo test --workspace 就是一个);在此之前 cargo check --workspace --locked 会失败。检查 Cargo.lock 中只有这些 crate 的条目发生了变化。

  4. 运行 Etemenanki 的全部门槛, 包括 cargo check --workspace --locked。任何失败都会中止发布。

  5. Commit。 提交信息写明发布的内容:单个 crate 写 release etemenanki-protocols X.Y.Z,多个写 release etemenanki-protocols X.Y.Z, etemenanki-app X.Y.Z,四个一起发布时写 release etemenanki X.Y.Z。正文说明为什么 bump 是这个幅度。之后检查 git status 和 git show --stat。

  6. 按顺序发布, 每次一个被 bump 的 crate,确认其精确版本可见后再发布下一个:

    终端窗口
    cargo publish -p <package> --registry <registry>
    cargo info --registry <registry> <package>@<version>

    每个 crate 的 manifest 都有 publish = [...],指定它唯一可以发布到的 registry。索引可能会有短暂延迟,重试 cargo info 是合理的。

  7. 推送分支,使用 git push origin <branch>。Etemenanki 没有 release tag,它的 release commit 本身记录了版本。不要自创 tag 体系。

  1. 更新本次发布需要的内核依赖,等它们在 registry 中可见后,一次一个 package:

    终端窗口
    cargo update -p <package> --precise <version>

    如果 katana 需要新版本的 API 或行为,也要提高 Cargo.toml 中的版本要求。然后检查 Cargo.lock:每个更新的 crate 都解析到刚发布的版本,其 source 行是私有 registry 的索引,并带有 checksum,其他内容都没有变化。

  2. Bump katana 自身的版本,版本号从 Cargo.toml 中读取,而不是凭记忆。兼容的修复升 patch;更大的变化参照历史(katana 每次切换到新的内核主版本时都升了主版本)。同时更新 Cargo.lock 中的 katana 条目,任何不带 --locked 运行的 Cargo 命令(例如 cargo check)都可以做到:带 --locked 的门槛会拒绝与 Cargo.toml 不再一致的 lockfile。检查 lockfile 中其他内容都没有变化。

  3. 运行 katana 的全部门槛, 包括 cargo metadata --locked --no-deps --format-version 1 和 cargo check --locked。

  4. Commit 并打 tag。 历史上的做法是一个标题为 release vX.Y.Z 的 commit,加上一个带相同信息的附注 tag vX.Y.Z:

    终端窗口
    git commit -m "release vX.Y.Z"
    git tag -a vX.Y.Z -m "release vX.Y.Z"
    git rev-parse HEAD
    git rev-parse 'vX.Y.Z^{commit}'

    这些 tag 是附注 tag,所以要把 HEAD 与 tag 解引用后的 commit vX.Y.Z^{commit} 比较;直接 git rev-parse vX.Y.Z 打印的是 tag 对象本身。

  5. 先推送分支,再推送 tag:

    终端窗口
    git push origin <branch>
    git push origin vX.Y.Z

    推送 tag 会触发 release.yml,它用 --locked 构建 x86_64-unknown-linux-gnu 和 x86_64-unknown-linux-musl 二进制;如果任一二进制动态链接 OpenSSL,或 musl 二进制不是完全静态的,就会失败;最后把 katana-vX.Y.Z-linux-x86_64-gnu、katana-vX.Y.Z-linux-x86_64-musl 以及各自的 .sha256 作为 release 的附件发布。

如果 tag 已存在且指向另一个 commit,不要移动或强制更新它。

在每个修改过的仓库中,确认本地与远端一致:

终端窗口
git status --short --branch
git rev-parse HEAD
git ls-remote origin refs/heads/<branch>
git ls-remote origin 'refs/tags/vX.Y.Z*' # katana: the peeled ^{} line is the release commit
cargo info --registry <registry> <package>@<version> # every crate published

最后,检查 katana 的 Cargo.lock 把每个 etemenanki-* crate 都解析到了本次发布的版本,并按保持文档与代码同步所述,把这些页面更新到新版本。

出现以下任何情况时,停下来并报告实际的错误。不要绕过它。

情况 不可接受的绕过方式
分支无法 fast-forward,或已与远端分叉 merge 或 rebase 后继续
registry 认证失败,或 registry 配置缺失 发布到别的地方
发布时报告版本已存在 覆盖它,或不断 bump 直到发布成功
依赖无法解析,或 lockfile 的变化远超预期 删除 Cargo.lock 或全量执行 cargo update
任何门槛失败 跳过测试或降低 clippy 级别
新版本没有出现在 registry 中 照样让 katana 切换过去
tag 已存在且指向另一个 commit 移动 tag
SemVer 兼容性或 tag 约定不明确 靠猜

未提交的工作属于它的作者,优先级高于自动化。

  • 绝不运行 git reset --hard、git checkout -- <file> 或其他破坏性的恢复操作,也不要随意运行 git clean。
  • 绝不为了让某个命令或测试通过而删除、覆盖或隐藏别人未提交的修改。
  • 绝不 force push 分支,也绝不改写已推送的历史。
  • 与远端同步前先检查 git status。存在本地修改时,用 git pull --ff-only --autostash 同步。如果 pull 无法 fast-forward,就停下来:merge 还是 rebase 应由人来决定,而不是发布流程中的一步。
  • tag 一旦推送就不再移动。

每个开发者页面都在 frontmatter 中列出它所描述的仓库文件,以及最近一次核对时所依据的版本:

sources:
Etemenanki: [concepts/src/core.rs, concepts/src/runtime.rs]
katana: [src/traffic.rs]
verified:
Etemenanki: 596916d
katana: v3.0.1

在文档仓库中,pnpm check:drift(scripts/check-drift.mjs)会读取每个页面的这段 frontmatter,并在以下情况报告问题:

  • sources 下列出的某个仓库没有对应的 verified 条目,或该版本在同级 checkout 中不存在;
  • 某个列出的文件在已验证版本中不存在;
  • 任一列出的文件在已验证版本与文档仓库 docs-versions.json 中为该仓库记录的发布版本之间有 commit(它会把这些 commit 打印出来)。比较有意止于发布版本而不是 checkout 的 HEAD,因为 HEAD 上可能有未发布的工作;该文件中没有记录的仓库会一直比较到 HEAD;
  • 翻译页面的 sources 或 verified 与其英文页面不一致。

它最后输出 check-drift: N tracked page(s), M problem(s),只要有问题就以状态码 1 退出。每次发布后,把该仓库在 docs-versions.json 中的 ref 移到 release commit 或 tag,然后运行检查。对它报告的每个页面,重新阅读代码、更新正文、把 verified 移到新版本,并在同一个 commit 中更新中文页面的 frontmatter。修改配置键还意味着要更新 src/data/reference/ 下的字段表和 examples/ 下的示例,pnpm check:examples 会用真实二进制加 --test 逐个运行这些示例;随后 pnpm build 会在任何失效的链接或锚点上失败。

站点是公开的,代码仓库是私有的。页面通过仓库内的相对路径和符号名来指代代码,绝不使用行号或链接,也绝不包含真实的密钥、registry 名称或 URL,以及任何与特定部署相关的信息。