跳转到内容

已知限制

源码文件:45 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/src/outbound/freedom.rs
  • Etemenanki/app/src/outbound/udp_fanout.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/environment/src/dial/udp.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/protocols/src/vless/codec.rs
  • Etemenanki/protocols/src/vmess/codec.rs
  • Etemenanki/protocols/src/trojan/codec.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/socks/handshake.rs
  • Etemenanki/protocols/src/socks/protocol.rs
  • Etemenanki/protocols/src/socks/udp_link.rs
  • Etemenanki/protocols/src/vless/core.rs
  • Etemenanki/protocols/src/trojan/core.rs
  • Etemenanki/protocols/src/vmess/core.rs
  • Etemenanki/app/tests/integration/e2e_xray_mux.rs
  • Etemenanki/app/tests/integration/e2e_hysteria_inbound.rs
  • Etemenanki/app/tests/support/mod.rs
  • Etemenanki/protocols/tests/unit/vless/codec.rs
  • Etemenanki/protocols/tests/unit/mux/demux.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/unit/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/protocol.rs
  • katana/src/runtime.rs
  • katana/src/manager/node.rs
  • katana/src/manager/mod.rs
  • katana/src/api/mod.rs
  • katana/src/api/newv2board.rs
  • katana/src/api/sspanel.rs
  • katana/src/config.rs
  • katana/src/inbound.rs
  • katana/src/router.rs
  • katana/src/connector.rs
  • katana/src/outbound/freedom.rs
  • katana/src/rule.rs
  • katana/src/traffic.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/connector.rs
  • katana/tests/unit/api/newv2board.rs
  • katana/tests/unit/runtime.rs

本页汇总了本指南其余部分所描述版本的功能限制:Etemenanki 2.0.2(etemenanki-protocols 2.0.2;其他内核 crate 与 etemenanki-app 仍为 2.0.0)和基于 etemenanki-protocols 2.0.1 构建的 katana 3.0.1。标为已修复的条目描述的是条目中所指的较早版本,保留下来供仍在运行这些版本的运维人员参考。每一条都是运维人员会注意到、或贡献者会踩到的行为:一次断开连接的重载、一处被静默忽略的修改、一个按错误名称读取的面板字段。每一条都已在代码中确认,有测试的也已在测试中确认。

在报告一个可能早已为人所知的 bug 之前,或在修改某条目提到的组件之前,请先读一读本页:原因部分会指出负责的类型或函数。本页只列出功能行为。

限制 组件 状态
每次重载都会替换整个 generation etemenanki-app → Instance::reload 未解决
[log].level 不会被重载 etemenanki-app → init_tracing 未解决
配置引用的文件不受监视 etemenanki-app → Instance::reload、spawn_watcher 未解决
freedom 的 UDP 把每个域名只发往一个地址 etemenanki-app → ResolvingUdp 未解决
VLESS 和 VMess 出站把 UDP 关联发往其第一个目标 etemenanki-protocols → VlessDatagram、VMessDatagram 未解决
mux 载体重复发送下行帧,或打乱 VMess 上传数据 etemenanki-protocols → Demux 已在 2.0.1 修复
SOCKS UDP 关联会接收最先发来数据的地址 etemenanki-protocols → SocksInbound 已在 2.0.2 修复
引导失败的节点会一直停机 katana → NodeManager::run 已在 3.0.1 修复
部分 [node.api] 修改不会生效 katana → PanelClient、identity 已在 3.0.1 修复
只改 [dns] 的修改不会生效 katana → apply_reload 未解决
任何 [[outbound]] 修改都会重建所有节点的监听器 katana → StaticUpdate::Outbounds 未解决
续期的证书和替换的 geodata 不会被重新读取 katana → bring_up、build_router 未解决
networkSettings 下的 VLESS 传输层设置被忽略 katana → newv2board::Client::parse_v2ray 未解决
Xboard 拒绝 node_type = "hy2" katana → newv2board::Client::new 未解决
审计规则只看到请求的地址 katana → Dispatcher::forbidden 未解决
流量计数器只保存在内存中,超时的上报可能被重复计费 katana → NodeManager::report_traffic 未解决
不上报在线用户、存活 IP 和节点状态 katana → PanelClient 未解决

每一条都由相同的四部分组成。现象是你看到的情况。原因用一两句话说明涉及的组件和机制,随后给出相应的代码。规避方法是在你所运行的版本上可以采取的做法。状态说明后续版本是否改变了该行为,以及由哪个测试(如有)固定该行为。

现象。 保存配置文件会关闭所有入站上的所有已打开连接,包括设置未变化的入站,以及只改了注释的保存。有一瞬间没有任何入站在监听。如果某个 Hysteria 2 入站持有活跃连接,或存在 TUN 入站,这段空档可能长达数秒。

原因。 app/src/instance.rs 中的 Instance::reload 从不就地修补正在运行的 generation。它根据文件构建一个完整的 Built,取消旧 generation 的 CancellationToken,等待每个 accept handle 结束、直到旧监听器交还端口,然后才通过 spawn_generation(built, false) 绑定新 generation。取消 token 会丢弃所有每连接任务。generation 之间不会移交连接。

app/src/instance.rs
pub struct Instance {
path: PathBuf,
state: tokio::sync::Mutex<State>,
}
struct State {
config: Config,
last_bytes: Vec<u8>,
generation: Generation,
}
struct Generation {
token: CancellationToken,
accept_handles: Vec<JoinHandle<()>>,
}
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)
}

当 strict 为 false 时,重载中的绑定失败会记录为 inbound <tag> bind <addr> failed: <error>,其他入站照常启动。该入站的旧监听器已经关闭,因此它会一直停机,直到后续某次重载成功绑定它(何时会发生,见后面几条)。

规避方法。 把多处修改合并为一次保存,并在可以接受重连的时候进行。客户端会自行重连;Hysteria 2 客户端会看到连接以应用错误码 0x100 关闭,然后重连到新 generation。

状态。 未解决。这是 2.0.0 中 generation 模型的工作方式。app/tests/integration/e2e_hysteria_inbound.rs 中的 a_reload_rebinds_the_udp_port 在一条 Hysteria 2 连接活跃时触发重载,并检查该入站重新监听原端口。没有测试断言流连接会被断开。完整流程见 Generation 与热重载。

现象。 修改 [log].level 并保存后,会记录一条包含 log changed 的 config reload: 日志,替换 generation(如上所述会断开连接),但日志详细程度保持不变。

原因。 app/src/main.rs 中的 main 在最开始调用一次 init_tracing。它在未设置 RUST_LOG 时读取 [log].level(默认 info),构建 EnvFilter,并用 .init() 安装 subscriber。它不保留任何 reload handle,Instance::reload 也不触碰 tracing。

app/src/main.rs
fn init_tracing(config_path: &Path)

规避方法。 重启 etemenanki-app 来修改级别,或在其环境中设置 RUST_LOG,它的优先级高于配置文件。

状态。 未解决。katana 会重载日志级别:apply_reload 会调用 katana 的 init_tracing 返回的 LogReload setter。

现象。 三个相关的效果:

  • 续期的证书或私钥、替换的 geoip 或 geosite 文件、新的 DNS ca_file 都不会被使用,尽管配置按同一路径引用它们。
  • 因引用的文件缺失而失败的重载,在文件出现后不会重试。
  • 在重载中绑定失败的入站,在端口空闲后也不会恢复。

原因。 spawn_watcher 以非递归方式监视配置文件所在的父目录,把其中每个事件在 200 ms 防抖后转为一次重载尝试。Instance::reload 随后把文件字节与 State::last_bytes 比较,相同则立即返回。引用的文件只在 build 内读取,因此只有配置字节变化时才会重新读取。解析或构建失败时,以及某个入站绑定失败的重载之后,last_bytes 也会被更新,所以同样的字节永远不会被尝试第二次。

flowchart TB
  ev["配置目录中的 notify 事件"] --> deb["防抖 200 ms"]
  deb --> read["std::fs::read(path)"]
  read --> same{"bytes == last_bytes?"}
  same -- 是 --> none["返回,什么也不发生"]
  same -- 否 --> parse{"config::parse_bytes 成功?"}
  parse -- 否 --> keep["记录 ERROR,last_bytes = bytes,旧 generation 继续服务"]
  parse -- 是 --> build{"build 成功?读取证书、geodata、CA 文件"}
  build -- 否 --> keep
  build -- 是 --> swap["取消旧 token,等待 accept handle"]
  swap --> spawn["spawn_generation(built, false)"]
  spawn --> done["last_bytes = bytes"]

位于配置目录之外的证书连 watcher 事件都不会产生。

规避方法。 替换引用的文件或释放端口后,改变配置字节(例如编辑一条注释),或者重启。无论哪种方式,整个 generation 都会被替换,连接会断开。原样保存文件不会有任何效果。

状态。 未解决。没有测试覆盖。

freedom 的 UDP 把每个域名只发往一个地址

Section titled “freedom 的 UDP 把每个域名只发往一个地址”

现象。 在某个地址族不可用(没有 IPv6 路由或没有 IPv6 socket)的主机上,经由 freedom 出站、发往同时解析出两种地址族的域名的 UDP,可能整个关联都失败:发往该域名的每个数据包都会被丢弃。而发往同一域名的 TCP 正常,因为 TCP 拨号会依次尝试每个地址。

原因。 app/src/outbound/freedom.rs 中的 ResolvingUdp::poll_target 用 destination_to_socketaddrs 解析域名,后者以 FamilySupport::both() 应用出站的 address_family 策略,然后只保留 addrs.first()。它不考虑 bind_dual 实际绑定了哪些地址族,并在关联的整个生命周期内把结果(无论成功或失败)缓存在 resolved 中。如果第一个地址所属的地址族没有 socket,DualStackUdp::poll_send_to 会以 udp: no local socket in the family of <peer> 失败;如果该地址族没有路由,内核会拒绝发送。两种情况下运行时都报告 SendFailed,数据包被丢弃。

app/src/outbound/freedom.rs
pub struct ResolvingUdp {
socket: DualStackUdp,
resolver: Resolver,
strategy: AddressFamilyStrategy,
resolved: HashMap<CompactString, Option<IpAddr>>,
resolving: Option<(CompactString, ResolveFuture)>,
}
impl ResolvingUdp {
fn poll_target(&mut self, cx: &mut Context<'_>, to: &Destination) -> Poll<Option<SocketAddr>>
}

规避方法。 在单栈主机上,为 freedom 出站设置 address_family = "ipv4_only"(或 "ipv6_only"),使候选地址只包含主机可达的地址。如果每个域名都有 IPv4 地址,"prefer_ipv4" 也有帮助,因为它把 IPv4 排在前面。

状态。 在 etemenanki-app 中未解决。没有测试覆盖地址族的选择。katana 自己的直连出站(katana 中的 src/outbound/freedom.rs)根据它实际绑定的 socket 构建 FamilySupport,只解析到这些地址族。拨号器一侧见 拨号器与 socket 策略。

VLESS 和 VMess 出站把 UDP 关联发往其第一个目标

Section titled “VLESS 和 VMess 出站把 UDP 关联发往其第一个目标”

现象。 通过同一个 vless 或 vmess 出站与多个对端通信的 UDP 关联,只能到达第一个对端。之后路由到该出站的每个数据包,无论原本发往哪个地址,都会发往第一个对端,而每个回复都被报告为来自第一个对端。etemenanki-app 和 katana 都受影响。

原因。 VLESS 和 VMess 的 UDP 请求在请求头中只指定一个目标,客户端 codec 不为每个数据包携带地址。seal_to 忽略其 to 参数,open_from 把每个回复都归到存储的目标上。UDP fan-out(app/src/outbound/udp_fanout.rs 中的 FanOutLink、katana src/connector.rs 中的 FanOut)为每个出站而不是每个目标保留一个子链路,以出站的 Arc 指针为键,上限为 MAX_SUBS = 64,因此第二个对端会复用第一个对端的子链路。

concepts/src/core.rs
pub type OpenedFrom = (Opened, Option<Destination>);
pub trait ProxyCoreEncodeDatagram: ProxyCoreEncodeHandshake {
fn seal_to(
&mut self,
plain: &[u8],
to: &Destination,
out: &mut Staging<'_>,
) -> Result<Option<()>, Self::Error>;
fn open_from(&mut self, wire: &mut [u8]) -> Result<OpenedFrom, Self::Error>;
}
protocols/src/vless/codec.rs
pub struct VlessDatagram {
header: BytesMut,
target: Destination,
replied: bool,
}
impl ProxyCoreEncodeDatagram for VlessDatagram {
fn seal_to(
&mut self,
plain: &[u8],
_: &Destination,
out: &mut Staging<'_>,
) -> io::Result<Option<()>>;
fn open_from(&mut self, wire: &mut [u8]) -> io::Result<OpenedFrom>;
}

protocols/src/vmess/codec.rs 中的 VMessDatagram 结构相同:它的 seal_to 同样接收 _: &Destination,它的 open_from 对每个数据帧都返回 Some(self.target.clone())。

规避方法。 把需要到达多个对端的 UDP 路由到为每个数据包指定地址的出站:在 etemenanki-app 中用 trojan(TrojanDatagram::seal_to 把 to 写入每个数据包)或 socks;katana 没有 trojan 出站,可用 socks 或 wireguard。也可以用路由规则把各个对端分散到不同出站。

状态。 未解决。这些出站不支持 XUDP。protocols/tests/unit/vless/codec.rs 中的 datagram_codec_frames_to_the_fixed_target 固定了该行为:发往其他地址的数据包仍按固定目标封帧。katana 一侧见 Connector 与 UDP fan-out。

mux 载体重复发送下行帧,或打乱 VMess 上传数据

Section titled “mux 载体重复发送下行帧,或打乱 VMess 上传数据”

现象。 使用启用了 mux 的 Xray 客户端时:

  • 在 VLESS 或 Trojan 上,客户端在某条 mux 连接上收到数据后只要再发送任何内容,服务端就会把最近的下行帧再发一遍。多路复用的 TCP 流会收到重复字节而损坏;基于 XUDP 的 UDP 子流会收到重复的数据包。
  • 在 VMess 上,大量上传的数据到达时会被打乱:当一个 mux 帧被拆分到两个 VMess chunk 中,且同一次读取中还有下一个 chunk 到达时,已完成帧的部分字节会丢失。

katana 3.0.0 节点同样受影响,因为它们使用 etemenanki-protocols 2.0.0 中的这些协议核心提供服务。在测试套件中,app/tests/integration/e2e_xray_mux.rs 里的两个 Xray 互通测试在 054cf34(2.0.0)上失败:xudp_attributes_replies_to_the_right_peer 和 vmess_mux_payload_spans_both_framings。两者只在能构建 Xray 时运行:app/tests/support/mod.rs 中的 build_xray 用 go 从参考源码树编译 Xray。缺少 go 或构建失败时,这些测试会打印一行 SKIP: 并通过。

原因。 在 2.0.0 中,Demux(protocols/src/mux/demux.rs 中的 mux.cool 服务端)有两个相互独立的缺陷:

  • 帧重复发送(VLESS、Trojan)。 Demux::out 保存上一次调用产生的帧。下行调用(on_outbound、on_datagram、on_outbound_gone)会先清空再重新填充它,而 VlessCore::on_sub 和 TrojanCore::on_sub 暂存 demux.out() 时并不将其取走。上行调用 Demux::feed 不清空它,只为被拒绝的会话追加 End 帧。随后载体会暂存 take_out() 返回的全部内容,于是已发送过的帧又被发送一次。
  • 上传数据被打乱(VMess)。 VMessCore 对每个打开的 chunk 调用一次 feed_whole。跨越 chunk 边界的帧在持有缓冲区中补全,并通过 Effect::ForwardHeld 或 Effect::SendToHeld 从该缓冲区转发,而运行时在整个事件处理完后才应用这些 effect。每次 feed_whole 调用都以 trim_held 开始,它把持有缓冲区中 partial_at 之前的内容清掉,因此同一次读取中的第二个 chunk 会删除第一个 chunk 的持有转发仍然指向的字节。

2.0.0 中的签名(2.0.1 取代了 feed_whole,见状态):

protocols/src/mux/demux.rs (2.0.0)
pub const MAX_SESSIONS: usize = 256;
impl<T> Demux<T> {
pub fn out(&self) -> &[u8];
fn trim_held(&mut self);
}
impl<T: Send + Sync + 'static> Demux<T> {
pub fn feed<C>(
&mut self,
plain: &[u8],
base: usize,
fx: &mut Effects<'_, C>,
) -> io::Result<usize>
where
C: ProxyCoreDecode<Key = FlowKey, Target = Flow<T>>;
pub fn feed_whole<C>(
&mut self,
plain: &[u8],
base: usize,
fx: &mut Effects<'_, C>,
) -> io::Result<()>
where
C: ProxyCoreDecode<Key = FlowKey, Target = Flow<T>>;
pub fn take_out(&mut self) -> Vec<u8>;
pub fn on_outbound(&mut self, key: SubKey, data: &[u8]);
pub fn on_datagram(&mut self, key: SubKey, from: &Destination, data: &[u8]);
}

帧重复发送解释了 XUDP 测试的失败:收到第一个对端的回复后,客户端向第二个对端发送数据,这次上行事件又把第一个对端的回复暂存了一次,于是客户端可能先于第二个对端的回复收到它。

sequenceDiagram
  participant C as Xray 客户端
  participant K as VlessCore
  participant D as Demux
  participant P as 对端 A 子流
  C->>K: 发往对端 A 的 New 帧
  K->>D: feed,然后 take_out(空)
  P->>K: 对端 A 的回复
  K->>D: on_datagram 填充 out
  K->>C: stage(demux.out()),out 保留该帧
  C->>K: 发往对端 B 的 Keep 帧
  K->>D: feed 不追加任何内容
  K->>D: take_out 返回对端 A 的帧
  K->>C: 对端 A 的回复被第二次暂存

规避方法。 在连接受影响版本的 VLESS、Trojan 或 VMess 入站的 Xray 客户端上关闭 mux,或者升级到 etemenanki-protocols 2.0.1(katana 3.0.1)。

状态。 已在 etemenanki-protocols 2.0.1 修复。两个上行调用 feed 和 Demux::feed_chunks 都在每次调用开始时清空 out,因此 out 只保存上一次调用的帧,载体暂存过一次的帧不会再被暂存。feed_chunks 取代了 feed_whole:VMessCore 在一次调用中把一次读取的所有 chunk 交给它,它每次读取只修剪一次持有缓冲区,因此该次读取的每个持有转发仍能找到自己的字节。2.0.1 在 protocols/tests/unit/mux/demux.rs 中新增了回归测试 a_downlink_frame_is_not_sent_again_by_the_next_uplink 和 vmess_keeps_every_frame_one_read_completes,并给 vless_answers_mux_at_once_and_demultiplexes 增加了一次必须不暂存任何内容的上行。2.0.1 修复了上面两个 e2e_xray_mux 测试的失败。katana 3.0.1 基于 2.0.1 构建。载体设计见 mux.cool 与 XUDP。

SOCKS UDP 关联会接收最先发来数据的地址

Section titled “SOCKS UDP 关联会接收最先发来数据的地址”

现象。 在 etemenanki-protocols 2.0.1 及更早版本上,如果另一台主机,或客户端所在主机上的另一个 socket,先于客户端向关联的中继端口发送了一个数据报,经 socks 入站的 UDP 就会失去响应。此后中继转发的是该发送方的数据报,每个回复都发给它,而客户端自己的数据报会被丢弃,直到关联结束。控制连接保持打开,因此客户端看不到任何错误。katana 没有 SOCKS 入站,不受影响。

原因。 在 2.0.1 及更早版本中,protocols/src/socks/server.rs 中的 SocksInbound::associate 用 get_or_insert 把 hub 收到的第一个数据报(无论内容如何)的来源设为关联的 client,且从不与控制连接的对端比较。握手读取了请求的 DST.ADDR 和 DST.PORT,然后将其丢弃。

规避方法。 在较旧的构建上,用防火墙限制发往中继地址(udp_bind,未设置时为客户端所连接的 IP)的 UDP,只让客户端地址能够到达。每个关联都把 hub 绑定到端口 0,内核每次都会挑选新的临时端口,因此规则必须覆盖整个地址,而不是某一个端口。如果该入站的客户端不需要 UDP,设置 udp = false。

状态。 已在 etemenanki-protocols 2.0.2 修复。基于该版本构建的 etemenanki-app 包含此修复,但 etemenanki-app 包的版本没有提升,因此 etemenanki-app --version 仍然输出 2.0.0。公共 API 没有变化。

associate 现在会在绑定 hub 之前构造一个 ExpectedSender。它接收控制连接的对端(source,在 Unix socket 上为 None)、请求中的 DST.ADDR 和 DST.PORT(由 protocols/src/socks/handshake.rs 中 crate 私有的 handshake_with_udp_source 与 Handshake 一并返回),以及 hub 的 IP。

protocols/src/socks/server.rs
const STATUS_NOT_ALLOWED: u8 = 0x02;
struct ExpectedSender {
ip: IpAddr, // canonical
port: Option<u16>,
client: Option<SocketAddr>, // where replies go, once pinned
}
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
pub(crate) fn endpoint(addr: SocketAddr) -> (IpAddr, u16)

ExpectedSender::new 决定关联接收谁的数据报。声明的地址只有在是非未指定(unspecified)的 IP 时才算数;域名或 0.0.0.0 不指向任何来源。

控制连接 请求声明的是 关联接收
来自对端 P 的 TCP P 加非零端口 从一开始就只接收 P 在该端口上的数据报。
来自对端 P 的 TCP P 加端口 0、任何其他 IP、未指定地址或域名 P,端口取第一个被转发的数据报的端口。声明的来源被搁置,既不信任也不拒绝。
Unix socket 非未指定的 IP 加非零端口 恰好是该地址和端口。
Unix socket 其他任何情况 不接收任何人:以 0x02 拒绝。

声明的来源之所以被搁置而不是被拒绝,是因为客户端会声明其数据报实际上并不来自的地址:sing-box 在第一个目标是私有地址时会声明另一地址族的回环地址,NAT 后的客户端会声明自己的局域网地址,PySocks 只声明端口。声明另一个地址永远不会让关联向该地址开放。

随后 hears(hub, ip) 检查 hub 能否接收来自该 IP 的数据报:IPv4 或 IPv4 映射的 hub 只接收 IPv4,未指定的 IPv6 hub(::)接收两个地址族,其他 IPv6 hub 只接收 IPv6。如果不能接收,或者 Unix socket 上的请求没有声明确切的来源,associate 会应答 0x02(规则集不允许连接)并以 PermissionDenied 失败,此时尚未绑定任何 hub。

在 hub 上,每个数据报在解析之前都要先经过 admits。admits 通过 endpoint 进行比较,endpoint 把 IP 规范化(IPv4 映射的 IPv6 地址视同对应的 IPv4 地址),并忽略 IPv6 的 flow info 和 scope。它要求 IP 与预期一致,如果声明了端口则端口也一致,关联一旦固定(pin)后还要与固定的端口一致。其他数据报一律不读直接丢弃。只有能成功解析且带有载荷的数据报才会触发 pin,因此与客户端同地址的邻居无法用垃圾数据抢占关联,而且第一次固定之后不再改变。回复发往 client(),采用 hub 看到的地址形式。

升级后运维人员可能注意到:

  • Unix socket 上的 socks 入站的客户端必须声明其数据报将来自的确切地址和端口。任一项为零或声明域名的客户端会收到 0x02。
  • 如果中继绑定在另一个地址族、永远无法接收客户端的数据报,会以 0x02 拒绝,而不是悄无声息地失去响应。例如客户端通过 IPv4 连接而配置了 udp_bind = "::1"。
  • 来自控制连接之外任何 IP 的数据报都会被丢弃。UDP 与 TCP 连接从不同地址发出的客户端将不会被接收。
  • 在 etemenanki-app 中,被拒绝的关联会以一行 debug 日志结束连接,例如 socks connection from Some(192.0.2.10) ended: socks: UDP associate from an address family the relay is not bound in,在 Unix socket 上则为 socks connection from None ended: socks: UDP associate over a unix socket must name its source address and port。
  • 在客户端一侧,protocols/src/socks/udp_link.rs 中的 SocksUdpLink 也通过 endpoint 比较回复来源与中继,因此双栈 socket 能接收 IPv4 中继从其 IPv4 映射地址发来的回复。它自己的请求仍声明 0.0.0.0:0,按上表会被搁置。

以下测试固定了该行为:

测试 文件 固定的行为
udp_association_ignores_another_ip(Linux) protocols/tests/pipeline/socks.rs 127.0.0.2 上的主机先发送、之后再发送,都既不被转发也不被应答。
udp_association_ignores_another_port_once_pinned protocols/tests/pipeline/socks.rs 第一个数据报之后,客户端 IP 上的另一个 socket 不会被接收。
udp_association_holds_to_the_port_the_request_names protocols/tests/pipeline/socks.rs 声明客户端自身地址和端口的请求在任何数据报到达前就把关联固定在该地址上。
udp_association_sets_aside_a_source_it_cannot_hold_to protocols/tests/pipeline/socks.rs IPv4 客户端声明 [::1]:0 的请求会被批准并正常工作。
udp_association_is_not_widened_by_the_request(Linux) protocols/tests/pipeline/socks.rs 声明另一个地址不会让该地址进入。
udp_association_over_a_unix_socket_needs_its_exact_source(Unix) protocols/tests/pipeline/socks.rs Unix socket 上的 0.0.0.0:0 收到 0x02 且连接被关闭;确切的来源会被固定在其端口上。
udp_association_refuses_a_relay_that_cannot_hear_the_client protocols/tests/pipeline/socks.rs IPv4 客户端配合 udp_bind = ::1 收到 0x02 且连接被关闭。
udp_link_ignores_datagrams_not_from_the_relay protocols/tests/pipeline/socks.rs SocksUdpLink 忽略来自另一个 socket 的格式正确的回复。
udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay(Linux) protocols/tests/pipeline/socks.rs [::] socket 上的 SocksUdpLink 能接收 IPv4 中继。
only_the_control_peer_is_heard_and_its_first_datagram_pins_the_port、an_ipv4_mapped_address_is_the_ipv4_one、a_request_naming_the_peer_pins_its_port_up_front、a_request_naming_any_other_source_is_set_aside、over_a_unix_socket_the_request_must_name_the_exact_source、a_relay_that_cannot_hear_the_client_is_refused protocols/tests/unit/socks/server.rs 按上表逐一检查 ExpectedSender 和 hears。
endpoint_sees_through_ipv4_mapping_and_ignores_flow_info protocols/tests/unit/socks/protocol.rs endpoint 把 IPv4 映射地址等同于对应的 IPv4 地址,并忽略 flow info。

驱动及其中继见 SOCKS。

katana 的好几条都与热重载有关。为便于理解,下图展示了保存的配置经过 src/runtime.rs 中的 apply_reload 再到达各节点的路径:

flowchart TB
  load["config::load 成功,500 ms 防抖之后"] --> ob{"new.outbounds != cfg.outbounds?"}
  ob -- 是 --> pool["build_outbounds:新的出站池和 Resolver"]
  pool -- 错误 --> reject["记录错误,不应用任何内容"]
  pool -- 成功 --> nodes{"为每个新增节点执行 build_node,为每个配置有变化的保留节点执行 PanelClient::new"}
  ob -- 否 --> nodes
  nodes -- 错误 --> reject
  nodes -- 成功 --> lvl["日志级别有变化则切换"]
  lvl --> fan["若重建了出站池,向每个节点发送 StaticUpdate::Outbounds"]
  fan --> rm["取消并等待身份已不存在的节点"]
  rm --> ids{"每个新节点:该身份是否已在运行?"}
  ids -- 否 --> spawn["用预先构建的管理器执行 spawn_built"]
  ids -- 是 --> eq{"NodeConfig 有变化?"}
  eq -- 是 --> upd["StaticUpdate::Config"]
  eq -- 否 --> skip["该节点无操作"]

节点的身份及其监听的通道:

src/runtime.rs
type NodeId = (String, String, u32, String, String);
fn identity(cfg: &NodeConfig) -> NodeId
struct NodeHandle {
id: NodeId,
cfg: NodeConfig,
task: JoinHandle<()>,
static_tx: mpsc::Sender<StaticUpdate>,
shutdown: CancellationToken,
}
src/manager/mod.rs
pub enum StaticUpdate {
Config(Box<NodeConfig>),
Outbounds(Arc<HashMap<CompactString, Arc<Outbound>>>),
}

identity 返回 (panel_type lowercased, api.host, api.node_id, api.key, panel node type)。面板节点类型是 newV2board 节点请求时使用的 node_type 查询参数(启用 enable_vless 的 V2ray 系节点,即 node_type 为 V2ray、Vmess 或 Vless,为 vless,否则为小写的 node_type),因为 UniProxy 按节点 id 和该类型查找节点;对于 sspanel 它为空,因为 sspanel 只按 id 查找节点。日志中身份显示为 <panel>@<host>#<node_id>,节点类型不为空时后接 /<node type>,且从不显示 key。

在应用任何内容之前,重载新增的每个节点都必须构建成功(面板客户端和路由器;若有新出站池,路由器针对新池编译),配置有变化的每个保留节点都必须能构建面板客户端。只要有一个构建失败,katana 就以 ERROR 级别记录 reload: node <display_id>: <error>; keeping current config,并且不应用任何内容,连出站池和日志级别也不应用。tests/unit/runtime.rs 中的 a_reload_with_a_node_that_does_not_build_changes_nothing 固定了这一行为。启动时构建失败的节点从未启动,因此之后的每次重载都会把它当作新节点重新构建:只要该条目仍构建失败,每次重载都会被拒绝,直到你修正或删除它。节点一侧的处理见 节点管理器,进程一侧见 进程运行时与重载。

现象。 在 katana 3.0.0 中,katana 继续运行并为其他节点提供服务,但有一个节点始终不监听。启动时它以 ERROR 级别记录了下列日志之一:

条件 日志
面板对 node_info 返回错误 node <id>: node_info failed: <error>
面板没有返回内容(HTTP 304) node <id>: panel returned no node info
节点描述中的端口为 0(newV2board 则会让 node_info 以 newV2board: server port must be > 0 失败) node <id>: panel returned port 0
构建或绑定监听器失败 node <id>: initial start failed: <error>

之后对该节点的修改会记录 reload: reconfigured node …,但什么也不会改变。启动时面板有几秒不可达、DNS 尚未就绪、端口仍被其他进程占用,或者 在 Xboard 上使用 node_type = "hy2",都足以触发。

原因。 在 katana 3.0.0 中,src/manager/node.rs 中的 NodeManager::run 只做一次引导尝试,遇到上述任一失败就返回,节点任务随之结束。运行时保留了 NodeHandle,但从不监视它的 JoinHandle;由于接收端已不存在,之后的 static_tx.send(…) 会失败且不记录任何日志。src/runtime.rs 中的 run 只有在一个节点任务都无法 spawn 时才以 no nodes could be started 退出,而这在任何引导运行之前就已确定。

src/manager/node.rs
impl NodeManager {
pub async fn run(
self: Arc<Self>,
shutdown: CancellationToken,
mut static_rx: mpsc::Receiver<StaticUpdate>,
)
}

规避方法。 在 katana 3.0.0 上,排除原因后重启 katana。如果只想重启受影响的节点,可以修改其 3.0.0 身份元组中的某个字段(例如 [node.api].timeout)并保存:重载会移除失效的节点并 spawn 一个新节点,新节点会重新引导。在 katana 3.0.1 中,timeout 不再属于身份,也不再需要规避。

状态。 已在 katana 3.0.1 修复。NodeManager::bootstrap 会重试失败的尝试,直到节点启动。等待时间从 1 秒开始,每次失败后翻倍,最多 60 秒,且不超过 controller.update_periodic。每次失败都以 ERROR 级别记录 node <id>: <reason>; retrying in <n>s,其中 <reason> 为 node_info failed: …、panel returned no node info、panel returned port 0、user_list failed: …、panel returned no user list 或 initial start failed: …。用户列表获取失败现在也会让这次尝试失败,而不是以无用户状态启动节点。每次尝试都会丢弃面板 ETag,使面板返回完整响应而不是 HTTP 304。等待期间保存配置修改会提前结束这次等待,下一次尝试立即开始;取消(移除该节点的重载或关闭进程)仍会停止重试。tests/unit/e2e.rs 中的 a_node_comes_up_once_the_panel_answers、a_node_whose_port_is_taken_comes_up_once_it_is_free 和 a_node_that_never_bootstraps_still_stops 固定了重试行为。

现象。 在 katana 3.0.0 中,修改 api.node_type、api.enable_vless、api.vless_flow、api.speed_limit、api.rule_list_path 或 api.disable_custom_config 后,会记录 reload: reconfigured node <panel>@<host>#<id>,而节点的行为与之前相同。api.enable_vless 只生效了一半:监听器会被重建(节点的连接会断开),但面板客户端仍按旧的节点类型请求和解析。关闭它后仍会继续提供 VLESS,因为只要配置标志或面板客户端的 NodeInfo.enable_vless 任一为真,src/inbound.rs 中的 build_protocol 就会提供 VLESS。

两个例外和一个相关的缺口:

  • 对于在本地描述的 Hysteria 2 节点(设置了 [node.hysteria].port),NodeManager::node_info 会在下一次轮询时根据当前配置(包括 api.speed_limit)构建节点描述。面板客户端仍把旧值作为每个用户的覆盖值,实际速率取两者中较低者,因此调低限速会生效,调高则不会。
  • rule_list_path 被缓存了,但它指向的文件在每次规则刷新时都会重新读取,所以对文件内容的修改会被读到。
  • 在 [node.api] 之外,对 controller.disable_sniffing 或 [node.hysteria] 的修改会被保存,但要到节点下一次重建监听器时才生效,因为 apply_static 不比较这些字段。

原因。 在 katana 3.0.0 中,每个节点的 PanelClient 只在 spawn_node 中构建一次,NodeManager 以不可变方式持有它。面板客户端在构造时从 NodeConfig 中复制了这些字段。identity 不包含这些字段,因此修改其中之一会变成一个 StaticUpdate::Config,而 NodeManager::apply_static 只检查 controller.listen_ip、controller.cert、api.enable_vless 和 route。新值被保存下来,却永远到不了客户端。

字段 是否被 newv2board::Client 缓存 是否被 sspanel::Client 缓存
node_type 是,作为 node_type 以及 node_type 查询参数 是
enable_vless 是:查询参数、settings 键和 NodeInfo.enable_vless 是
vless_flow 不读取 是
speed_limit 是,作为 speed_limit_mbps 是
rule_list_path 是 是
disable_custom_config 不读取 是

规避方法。 在 katana 3.0.0 上,重启 katana,或在同一次保存中修改 [node.api].timeout,让节点以新的客户端重新 spawn。两种方式都会断开节点的连接。

状态。 已在 katana 3.0.1 修复:

  • 在 newV2board 上,改变身份中面板节点类型的修改(node_type 的修改,仅改大小写的除外;或 V2ray、VMess 节点上的 enable_vless;VLESS 节点无论如何都以 vless 请求)会重新 spawn 节点,因为它现在指向另一个面板节点。
  • 身份之外的其他任何 [node.api] 修改都以 StaticUpdate::Config 到达运行中的节点,apply_static 根据修改后的配置构建新的 PanelClient。newV2board 客户端会接管前一个客户端读到的路由(inherit_routes),因此由这些路由派生的审计规则会一直保持,直到新客户端自己读取节点配置。随后节点立即用新客户端执行一次面板轮询,reconcile 阶梯只断开新响应所要求断开的部分:rule_list_path 或 timeout 这类只影响客户端的修改会保留节点的连接。
  • 对 controller.listen_ip、controller.cert、controller.disable_sniffing、api.enable_vless、[node.hysteria] 或路由的修改,会在那次轮询中强制重建监听器。
  • 一次修改要么整体生效,要么完全不生效。面板客户端构建失败的修改会被重载拒绝,如上文所述。路由器构建失败的修改会被节点拒绝,节点记录 node <id>: config edit refused, keeping the running one: <error>,并按原样继续运行。

tests/unit/runtime.rs 中的 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/api/newv2board.rs 中的 a_rebuilt_client_keeps_the_routes_it_has_not_read 固定了这些行为。

现象。 在只修改 [dns] 后,katana 不记录任何 reload: 日志,出站仍通过旧的解析器解析。

原因。 build_outbounds 构建内置 direct 处理器与所有 [[outbound]] 共享的那一个 Resolver。apply_reload 只在 new.outbounds != cfg.outbounds 时调用它。但它仍会用 *cfg = new 保存新配置,因此新的 [dns] 会在下一次构建出站池时生效。

规避方法。 重启 katana,或把 [dns] 的修改与一处 [[outbound]] 修改一起保存(这会重建所有节点的监听器,见下一条)。

状态。 未解决。

任何 [[outbound]] 修改都会重建所有节点的监听器

Section titled “任何 [[outbound]] 修改都会重建所有节点的监听器”

现象。 添加、删除或修改任何 [[outbound]],即使是没有任何路由使用的那个,也会断开所有节点上的所有连接。katana 记录 reload: outbound pool rebuilt,每个节点记录 node <id>: router rebuilt,有用户的节点还会记录 node <id>: listening on <ip>:<port>。

原因。 重建的出站池会以 StaticUpdate::Outbounds 发送给每个节点。apply_static 保存它,用 rebuild_router 重新编译路由器,并调用 apply_route_change,后者执行一次完整的 rebuild:先 tear_down TransportManager,再 bring_up。katana 在路由变化时有意断开所有连接,而拆除监听器是结束所有连接(包括仍在握手中的连接)的唯一方式。路由器持有出站池中的 Arc<Outbound> handle,所以新的出站池总是意味着新的路由器。

对 [node.route] 的修改走另一条路径到达同样的重建:apply_static 先编译新路由器,编译失败则拒绝该修改(node <id>: config edit refused, keeping the running one: <error>,不重建任何东西),然后通过 poll_cycle(true) 强制重建。

如果新的出站池缺少某个运行中节点的路由所引用的 tag,build_router 会以 route references unknown outbound tag: <tag> 失败。节点记录 node <id>: route rebuild failed, keeping current: <error>,保留旧路由器,但仍会重建监听器。同一次保存中新增的节点会在应用任何内容之前用新的出站池编译,因此其路由中缺少 tag 会导致整个重载被拒绝。

规避方法。 把出站修改集中起来,在可以接受重连的时候进行。保存前运行 katana --test -c <file>:test_config 会用新的出站池编译每个节点的路由器,从而发现引用了不存在 tag 的路由。

状态。 未解决。tests/unit/e2e.rs 中的 route_change_drops_connections 固定了以 StaticUpdate::Config 发送路由修改时的断连行为;没有测试驱动 Outbounds 路径。

续期的证书和替换的 geodata 不会被重新读取

Section titled “续期的证书和替换的 geodata 不会被重新读取”

现象。 证书续期后如果文件路径不变,节点仍继续使用旧证书。替换后的 geoip 或 geosite 文件不会被使用。

原因。 src/inbound.rs 中的 build_transport 在 bring_up 构建监听器时读取 cert_file 和 key_file,src/router.rs 中的 build_router 在编译路由器时读取 geodata 文件。重载比较的是 NodeConfig 的值,在同一路径上替换文件不会让这些值发生变化,所以什么都不会被重建。

规避方法。 每次续期后重启 katana,例如在 ACME 客户端的 deploy hook 中执行。把 cert_file 和 key_file 指向新路径也可以,因为 controller.cert 变化会重建监听器。对于 geodata,保存一处对 [node.route] 或 [[outbound]] 的修改,或者重启。

状态。 未解决。

networkSettings 下的 VLESS 传输层设置被忽略

Section titled “networkSettings 下的 VLESS 传输层设置被忽略”

现象。 在当前的 Xboard 上,WebSocket 的 VLESS 节点无论面板向客户端显示什么路径,都在路径 / 上提供服务且不检查 Host;gRPC 的 VLESS 节点则以空的 service name 提供服务。使用面板所给路径或 service name 的客户端无法连接。基于 TCP 的 VLESS(无论是否使用 TLS)以及所有 VMess 节点不受影响。

原因。 src/api/newv2board.rs 中的 UniProxy 响应结构体把设置声明了两次,并且在设置了 enable_vless 时由 parse_v2ray 读取 network_settings。Xboard 的 ServerService::buildNodeConfig 把所有节点类型的设置都放在 networkSettings 下发送,因此 VLESS 节点读不到任何设置。随后 build_transport 在 WebSocket 路径为空时回退为 /,并直接使用空的 service name。

src/api/newv2board.rs
struct ServerConfig {
#[serde(default, rename = "networkSettings")]
network_settings: Option<NetworkSettings>,
#[serde(default, rename = "network_settings")]
vless_network_settings: Option<NetworkSettings>,
// …
}

规避方法。 在这类面板上,改用 TCP 加 TLS 提供 VLESS,或在面板中把 WebSocket 路径设为 /。把 VLESS 设置放在 network_settings 下发送的面板可以按预期工作。

状态。 未解决。tests/unit/api/newv2board.rs 中的 parse_v2ray_ws_tls 只覆盖 VMess 使用的键;没有测试覆盖 VLESS 使用的键。

现象。 katana --test 接受 node_type = "hy2",但运行时节点发出的每个 UniProxy 请求都会失败:

  • 当由面板描述节点时([node.hysteria].port 为 0),节点永远不会启动,并会无限重试引导,记录 node <id>: node_info failed: GET /api/v1/server/UniProxy/config; retrying in <n>s。
  • 当设置了 [node.hysteria].port 时,katana 不向面板请求节点描述,但用户列表仍会失败,而用户列表失败同样会让引导失败。节点不绑定任何端口并持续重试,记录 node <id>: user_list failed: GET /api/v1/server/UniProxy/user; retrying in <n>s。

reload: 日志把这样的节点显示为 <panel>@<host>#<node_id>/hy2,因为面板节点类型是其身份的一部分。

原因。 src/api/mod.rs 中的 NodeType::parse 接受 hysteria2、hysteria 和 hy2。newv2board::Client::new 把配置的 node_type 转为小写后,作为每个 UniProxy 请求的 node_type 查询参数发送(只有启用 enable_vless 的 V2ray 系节点会以 vless 发送)。Xboard 先把别名 v2ray → vmess、hysteria2 → hysteria 映射,再按自己的类型列表校验该参数,并以 Invalid node type specified 拒绝 hy2。--test 从不联系面板,所以无法发现这个问题。

规避方法。 写成 node_type = "hysteria2" 或 "hysteria"。

状态。 未解决。tests/unit/api/newv2board.rs 中的 node_type_param_vless 固定了其他节点类型的参数推导方式。

现象。 当客户端向 IP 地址发起连接时,即使其 TLS server name 或 HTTP Host 指向某个域名、且针对同一域名的路由规则能够匹配,该域名的审计规则也不会匹配这条连接。

原因。 src/connector.rs 中的 KatanaConnector::connect 把 flow.sniffed 传给 route_target 用于路由,但 Dispatcher::forbidden 用 dest_string(&flow.destination) 匹配规则,即请求的域名或 IP 字面量,不含端口。UDP 数据包也以同样方式审计,每个数据包按其自身的目标匹配。

src/rule.rs
impl RuleManager {
pub fn detect(&self, tag: &str, dest: &str, uid: Option<i64>) -> bool
}

规避方法。 在审计规则中加入 IP 模式,或用路由规则把该域名路由到 block 出站。对于 TCP,开启嗅探时路由能看到嗅探出的域名;UDP 数据包只按其目标路由。见 目标审计。

状态。 未解决。tests/unit/connector.rs 中的 a_forbidden_destination_is_refused_and_recorded 固定了规则按不含端口的请求地址匹配,并且命中会记录到该用户名下。

流量计数器只保存在内存中,超时的上报可能被重复计费

Section titled “流量计数器只保存在内存中,超时的上报可能被重复计费”

现象。 两个效果:

  • 如果 katana 被强制终止或崩溃,尚未上报的字节会丢失。正常关闭时每个节点会发送一次最终上报;如果这次上报失败,其中的字节同样会丢失。
  • 如果面板已经记录了某次上报,但其响应在 HTTP 超时之后才到达,katana 会把这次上报视为失败,并在下一个周期再次发送同样的字节,面板就会对它们重复计费。

原因。 src/traffic.rs 中的 NodeTraffic 把所有计数器保存在内存中,不做任何持久化。NodeManager::report_traffic 只在上报成功时提交:它用 UserCounter::commit_reported 精确减去已上报的字节,并丢弃残余行。遇到任何错误(包括超时)时,它调用 restore_residuals,不触碰实时计数器,因此下一个周期会再次上报这些字节。katana 向 UniProxy 的 push 端点或 SSPanel 的 /mod_mu/users/traffic 上报时不携带任何请求标识,所以双方都无法区分重发和新的上报,katana 也无法区分丢失的上报和慢的上报。超时时间为 [node.api].timeout 秒,0 表示 5 秒(ApiConfig::timeout_secs)。

src/traffic.rs
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)>)
}

规避方法。 用 SIGTERM 或 SIGINT 而不是 SIGKILL 停止 katana,让最终上报得以执行。把 [node.api].timeout 调到高于面板正常情况下最慢的响应时间。修改它会就地重建节点的面板客户端;除非用新客户端读到的节点信息改变了节点的协议或传输层,否则连接会保留。留意反复出现的 node <id>: report traffic: <error> 警告。

状态。 未解决。katana 有意选择重发而不是丢弃:丢弃上报会导致少计费。tests/unit/traffic.rs 中的 restored_residuals_are_retried 和 commit_reported_preserves_concurrent 固定了重发行为和精确扣减的提交方式。详见 流量计费,面向运维人员的说明见 流量上报。

不上报在线用户、存活 IP 和节点状态

Section titled “不上报在线用户、存活 IP 和节点状态”

现象。 面板看不到每个用户的在线 IP,无法实施设备数限制,也拿不到 katana 的节点状态。[node.api].device_limit 键会被接受,但从不读取。

原因。 src/api/mod.rs 中的 PanelClient 只有五个操作:node_info、user_list、report_user_traffic、node_rule 和 report_illegal。newV2board 客户端只调用 UniProxy 的 config、user 和 push 端点,从不调用 alive、alivelist 和 status。SSPanel 客户端从不调用 /mod_mu/users/aliveip,也不发送节点状态。

规避方法。 katana 中没有。

状态。 未解决:尚未实现。