Compare commits

...
69 Commits
Author SHA1 Message Date
lbl 298c3965e2 chore: bump version to 2.0.2 2026-08-23 11:37:34 +08:00
lbl 619a5bcf18 feat(web): share network configs via QR code 2026-08-23 11:03:28 +08:00
lbl 1acb5e8a86 feat(jni): expose detailed peer metrics 2026-08-23 10:28:37 +08:00
lbl e216416e21 fix(build): fall back to system protoc 2026-08-22 23:16:41 +08:00
lbl 7675a6868c fix(ci): restore FreeBSD and MIPS builds 2026-08-22 22:52:27 +08:00
lbl 70e584b37b chore(release): bump version to 2.0.1 2026-08-22 22:43:31 +08:00
lbl af4b5d9657 refactor(jni): remove legacy no_tun config field
fix(ui): clarify mapping exit description
2026-08-22 22:38:38 +08:00
lbl de0183139d feat: add TAP device mode and Ethernet interoperability 2026-08-22 21:47:49 +08:00
lbl 26a44e64bd build(web): build frontend UI automatically in build.rs
vnt-web/build.rs now runs the pnpm UI build when static/ is missing or
older than ui/src, so the embedded assets no longer need to be committed.
Set VNT_WEB_SKIP_UI_BUILD=1 to skip (a placeholder page is written).
Remove vnt-web/static from version control.
2026-08-22 04:12:46 +08:00
lbl 453234bf8d style: rustfmt tun general.rs 2026-08-22 04:05:10 +08:00
lbl 3e2274dfe5 fix(ui): simplify encryption password hint in config editor 2026-08-22 04:05:10 +08:00
lbl 53e092b63d refactor(ui): merge instances page into network dashboard
- Remove the redundant Instances page; its network detail and server
  connection sections now live in DashboardView below selectable
  instance cards
- Redirect legacy /instances and /general routes to /
- Add per-page subtitles to the header instead of a fixed caption
- Widen content container to 1700px for large screens
- Highlight online device count in green on the stats cards
2026-08-22 04:04:30 +08:00
lbl b7151880c0 feat(android): add JNI build and VPN support 2026-08-22 21:55:00 +08:00
lbl 60a4403f0e feat: add desktop updater and release automation 2026-08-22 10:00:24 +08:00
lbl da72670154 chore: update rand and resolve clippy warnings 2026-08-22 08:57:50 +08:00
lbl 2995cfcb26 fix(ui): refine web access and peer list 2026-08-22 08:52:25 +08:00
lbl 74c47bce69 feat: add outbound interface binding 2026-08-22 07:58:18 +08:00
lbl 55d47c5a1c feat: replace native select controls 2026-08-22 04:23:35 +08:00
lbl cd5b4431dc feat: add unified Tauri desktop client and secure web access 2026-08-22 04:09:44 +08:00
lbl 0ccd045e78 feat(vnt-web): 新增 /api/version,顶栏版本号不再依赖组网状态 2026-08-22 01:38:57 +08:00
lbl 39a4e60d39 feat(vnt-web): 配置变化提示与停止交互优化
- /api/info 新增 config_changed:与启动时的配置快照对比,启动后配置被修改(或文件缺失/解析失败)时实例卡片显示「配置发生变化」徽章,重启后自动消失
- 停止操作增加「停止中」中间态:徽章脉冲提示、按钮区禁用,实例真正停止后卡片渐隐消失,失败时自动恢复
- 停止/重启/移除确认弹窗优先显示配置名称,兜底文件名
2026-08-22 01:27:39 +08:00
lbl 6029fd113d build(vnt-core): 使用 vendored protoc,不再依赖系统安装 2026-08-22 01:27:28 +08:00
lbl 7513465b61 refactor(vnt-web): 前端重构为 Vite+Vue3+Pinia 工程并重新设计 UI
- 单文件 index.html 拆分为 vnt-web/ui(pnpm+Vite+Vue3 SFC+Pinia+Tailwind v4),构建产物输出到 static/
- UI 重新设计:浅色简洁风(可切换深色模式)、顶部导航、新增总览仪表盘首页
- 自定义弹窗/toast/确认框替换全部原生 alert/confirm,交互与过渡统一
- 响应式布局:移动端汉堡导航,表格与卡片自适应
- 实例卡片移除日志按钮;启动组网默认选中第一个配置
2026-08-22 00:53:51 +08:00
lbl 292d54fbc3 feat(vnt-web): 支持同时启动多个组网配置
- 后端实例状态从全局单槽改为按配置文件名隔离的 HashMap,每个实例持有
  独立的状态机、启动日志与任务组管理器
- 启动/停止/重启/信息/对端/路由/启动状态接口均按 file_name 维度隔离,
  新增 GET /api/instances 实例列表与 DELETE /api/instance 移除已停止实例
- 启动前冲突检测:device_id 相同(同服务器同组网范围内)或 tunnel_port
  显式相同则拒绝启动
- vnt_current_config.txt 改为每行一个配置名,重启后自动恢复所有运行中实例
- 前端重做为多实例模型:实例卡片列表、按实例隔离的启动日志弹窗、
  peers/routes 顶部实例切换器、配置列表运行状态标记、已停止实例可移除
2026-08-21 21:31:48 +08:00
lbl 94d380f9b6 update .gitignore 2026-08-21 19:33:18 +08:00
lbl 061135fb95 fix(vnt-web): save_config 强制 .toml 后缀
问题:list_configs 只列出 *.toml 文件(service_http.rs:812),而
save_config 对文件名不做后缀校验,可保存出列表中不可见、无法通过
Web 界面再次选择的配置文件。

修复:新增 normalize_config_file_name——无扩展名时自动补 .toml,已是
.toml 保持不变,其他扩展名直接拒绝并返回错误提示。

测试:新增 test_normalize_config_file_name 覆盖补后缀/保持不变/拒绝
.txt/.json 三种情况,cargo test -p vnt-web 4 个测试全部通过。
2026-08-21 03:11:14 +08:00
lbl 042254e5ac fix(cli): wintun.dll 不再只按存在性判断
此前仅当 wintun.dll 不存在时才释放内嵌版本,损坏或被替换的旧版
dll 会永久残留,后续创建 TUN 设备时才以难以理解的方式失败。

改为与内嵌版本做内容比较,不一致(不存在/损坏/旧版)即重写。

测试:缺失时写入、损坏时重写、一致时跳过。
2026-08-21 03:05:48 +08:00
lbl 45cce75085 fix(ipc): PORT 文件锚定 exe 目录且不再被多实例覆盖,读写加超时
问题:
1. PORT 文件用相对路径,读写位置随 CWD 变化。
2. 所有实例(显式端口、端口冲突退避到随机端口的)都写同一个
   PORT 文件,多实例互相覆盖,客户端可能连错实例;写入失败还会
   杀死整个 IPC 服务。
3. 客户端读响应、服务端读请求都没有超时,对端异常时永久挂起。

修复:
- PORT 文件锚定 current_exe 目录。
- 仅默认端口实例写 PORT 文件,写入失败降级为告警。
- 服务端读请求超时 10s,客户端读响应超时 5s。

测试:test_port_file_path_is_absolute 验证路径为绝对路径。
2026-08-21 03:02:22 +08:00
lbl c5403b6e31 fix(cli): 纯参数模式不再静默丢弃 --tunnel-port
build_from_args_only 用 ..Default::default() 填充其余字段,
args.tunnel_port 被丢弃,纯命令行参数模式下隧道端口设置无效。

顺带修复 vnt-ipc feature 下 main_cli.rs 的 clippy never_loop
(此前验证未覆盖该 feature):loop 两臂都直接 break,去掉 loop。

测试:test_args_only_keeps_tunnel_port 验证参数透传到 Config。
2026-08-21 02:55:08 +08:00
lbl e7b985c233 fix(utils): fallback 设备 ID 文件锚定可执行文件目录
machine_uid 获取失败时的 fallback 使用相对路径 "device_id",
读写位置随进程 CWD 变化,换个目录启动就会生成新 UUID,
设备 ID 漂移。

改为锚定 current_exe 所在目录(取不到时退回原相对路径行为)。

测试:test_fallback_id_path_is_absolute 验证路径为绝对路径。
2026-08-21 02:47:53 +08:00
lbl cc80bd2d37 fix(dns): 非法域名不再导致 panic
排查发现比报告更深一层的问题:dns-parser 0.8 的 add_question 对
超长 label 直接 assert panic(builder.build() 的 unwrap 只是第二层),
配置中的非法域名(如 label 超 63 字节)会使整个进程 panic。

修复:
- query() 调用前先经 is_valid_domain 校验(label 非空 ≤63 字节、
  全长 ≤253 字节,允许 FQDN 尾点),非法域名返回 InvalidInput。
- build().unwrap() 同步改为 map_err 作为纵深防御。

测试:非法域名 query 返回 InvalidInput 而非 panic;
is_valid_domain 合法/非法样例。
2026-08-21 02:45:46 +08:00
lbl 90a00215ca fix(port_mapping): UDP 映射 dest_map 不再无界增长,recv 出错不再杀任务
问题:
1. 每个新 src 创建映射条目与转发任务,任务虽有 60s 空闲超时退出,
   但映射条目从不移除,dest_map 随不同 src 数量无界增长。
2. recv_from 单次出错经 ? 传播直接杀死整个端口映射接收任务。

修复:
- 转发任务退出时经 remove_if_same(same_channel 身份比较)回收
  映射条目,防止误删重建后的新条目。
- recv_from 错误改为 log + 100ms 休眠 + continue。

测试:test_remove_if_same 覆盖旧任务误删新条目的竞态。
2026-08-21 02:39:24 +08:00
lbl 55d4b874bd fix(fec): 越界 packet_index 不再污染 group,payload 长度防 u16 截断
问题:
1. group 尺寸已知(校验包已到)后,数据分支对任意 packet_index
   resize received_shards 并写入,越界索引会把 shard 列表撑出
   data_shards+parity_shards,decode_with_rs 长度校验必然失败,
   整个 group 报废、FEC 恢复能力丧失;校验包索引落在数据区
   (< data_shards)还会直接覆盖数据 shard。
2. 内部批量格式的长度字段为 u16,payload.len() as u16 静默截断。

修复:
- 数据包:group.data_shards 已知时 packet_index >= data_shards 直接丢弃。
- 校验包:packet_index < data_shards 直接丢弃;
  received_shards 已被撑大(尺寸未知期的越界包)时放弃该 group。
- 长度字段改用 u16::try_from 检查转换(编码器本就有 Payload too big
  前置检查,解码器补齐对称防御)。

测试:越界数据包不污染 group(重构正常产出恢复包)、
数据区校验包索引被拒绝且 group 保持可用。
2026-08-21 02:36:20 +08:00
lbl 9993f9c894 fix(quic): TCP 解析失败不再静默丢包
open_quic_client 分支下,非分片 TCP 包若 TcpPacket 解析失败,
原实现 return true 表示"已处理",实际什么都没做——包被静默丢弃,
与文件中非 quic_client 路径(解析失败落到 return false)语义不一致。

改为 return false,把包交还给调用方的其他转发路径。

注:outbound() 依赖真实 IpStack,无法低成本构造单测;clippy 通过。
2026-08-21 02:27:46 +08:00
lbl 8ecbfca939 fix(quic): 入站 channel 满时丢包而非阻塞整条接收循环
QuicDataInbound::send 此前用异步 send 等待 channel 容量,消费端
(QUIC endpoint 驱动)处理不过来时,256 容量的 channel 一满就
阻塞整条接收循环,所有对端的入站流量被头部阻塞。

改为 try_send:满则丢包并告警(IP 包语义下可接受),关闭才报错。

测试:test_send_drops_when_channel_full(满时不阻塞)、
test_send_errors_when_channel_closed(关闭后报错)。
2026-08-21 02:25:40 +08:00
lbl 4f41468725 fix(quic): 按流发送任务增加空闲老化,不再永久残留
问题:每个 (protocol, src, dest) 三元组创建一条 QUIC 单向流和发送
任务,映射条目永不移除、rx.recv() 永不结束(map 持有 tx),流、
任务、映射随目的地址数量无界增长。

修复:
- 发送循环改为 120s 空闲超时,超时退出并关闭流。
- 任务退出时经 remove_sender_if_same(tokio Sender::same_channel
  身份比较)回收映射条目,避免误删重建后的新条目。

测试:test_remove_sender_if_same 覆盖旧任务误删新条目的竞态。
2026-08-21 02:09:30 +08:00
lbl cb3d1ba14d fix(p2p): NAT 信息更新不再发布 0.0.0.0、不再因 unwrap 瘫痪
问题:
1. 网卡扫描得到的 local_ipv4s 被 vec![local_ipv4] 整体覆盖;
   local_ipv4() 探测失败且扫描为空时回退为 0.0.0.0 并发布出去。
2. udp/tcp socket manager 及 local_ports 的多处 unwrap 在管理器
   未就绪时直接 panic,永久杀死 NAT 刷新任务(30 分钟循环)。

修复:
- 抽出 select_local_ipv4:探测地址无效时回退到扫描结果首地址,
  主地址并入列表首位且不覆盖其余扫描结果;无任何可用地址时
  跳过本次发布。
- socket manager 未就绪/查询失败时记录日志并跳过本次更新
  (tcp 端口缺失时置 0),30 分钟后正常重试。

测试:select_local_ipv4 四种场景(探测有效/回退扫描/去重/全空)。
2026-08-21 02:00:30 +08:00
lbl ed6f61ea2a fix(p2p): 路由评分分母 u32 溢出
rtt > 约39s 时 rtt * (10000 + loss_v * 10) 超出 u32 范围,
debug 构建下直接 panic,release 下评分错误。

改用 u64 计算并钳制回 u32。

测试:test_get_channel_score_large_rtt_no_overflow 覆盖 u32::MAX rtt
与正常大小关系。
2026-08-21 01:55:02 +08:00
lbl d49b7e6651 fix(jni): 全部 18 个 JNI 导出函数加 catch_unwind 防护
问题:vnt-jni 的 extern "system" 导出函数没有任何 panic 防护,
core 内任何 panic(unwrap/索引越界/断言等)跨 FFI unwind 会直接
abort 宿主 JVM。

修复:新增 catch_jni_panic(catch_unwind + AssertUnwindSafe)与
jni_guard! 宏,包裹全部 18 个导出函数:panic 时提取 payload 消息、
向 JVM 抛 RuntimeException 并返回该类型的默认值(jboolean/jint 0、
jlong -1、指针 null_mut、void 直接返回)。业务逻辑与签名不变。

说明:排查中发现报告举例的 core/mod.rs:140 expect 在当前代码下
实际不可达(no_tun=true 时 internal_nat_inbound 必为 Some),
但 FFI panic 防护本身仍然必要。

测试:3 个 catch_jni_panic 单测(正常值透传、&str/String payload
消息提取);cargo clippy --workspace --all-targets -- -D warnings 通过。
2026-08-21 01:45:30 +08:00
lbl 1cc71c8d62 fix: TCP 双向转发半关闭时不再截断对端数据
问题:tcp_port_mapping::stream_copy 与 tcp_nat::stream_nat 用
tokio::select! 组合两个 io::copy,任一方向先结束就 drop 另一方向。
客户端半关闭(shutdown 写方向)后,服务端的响应数据会被静默截断。

修复:抽出 copy_bidirectional_split,用 tokio::io::join 合并分离的
读写半部后走 copy_bidirectional——任一方向 EOF 时对另一端 shutdown
并继续转发剩余方向,直到双向完成。

测试:test_half_close_no_truncation 用 duplex 流验证半关闭后响应
完整送达(旧实现此处读到空数据)。
2026-08-20 23:20:06 +08:00
lbl 92463499ef fix(utils): TaskGuard::drop 不再调用 tokio::task::id()
问题:TaskGuard::drop 中调用 tokio::task::id()。abort 路径下
future 的销毁若发生在非任务上下文,id() 直接 panic 刷日志;
若发生在调用方任务上下文,则拿到调用方的任务 id,把它从
任务组中误删,破坏任务组记账。

修复:spawn 时在任务上下文内获取自身 id 存入 TaskGuard,
Drop 只使用保存的 id。

测试:test_abort_task_keeps_caller_bookkeeping 验证在同组任务内
abort 其他任务后,调用方条目不被误删、wait_all_stopped 正常返回。
2026-08-20 23:15:02 +08:00
lbl 837ef29c15 fix(tun): TUN 读写任一方向结束即整体退出,消除半开状态
此前 in_tun_loop/out_tun_loop 是两个独立任务,任一方向出错后
另一方向继续运行:TUN 处于半开状态,一个方向的流量被静默吞掉,
无恢复也无上报。

修复:两个方向合并为单个任务并用 tokio::select! 组合,任一方向
结束(出错或设备关闭)即取消另一方向并记录日志。DeviceTask
相应合并为单 SubTask。

注:依赖真实 TUN 设备,未做端到端测试;全部既有单测通过。
2026-08-20 23:12:23 +08:00
lbl 7fbac61e75 fix(server): 动态 DNS 发现的 wss:// 地址正确映射为 Wss 协议
此前动态 DNS TXT 记录中的 wss:// 被错映射为 TlsTcp,导致动态发现
模式下 WSS 连接实际不可用(会对 wss 端口发起裸 TLS over TCP)。

将前缀解析抽为纯函数 parse_dynamic_txt,wss:// -> ProtocolType::Wss,
其余映射保持不变。

测试:test_parse_dynamic_txt 覆盖 wss/quic/udp/tcp/ws/无前缀六种输入。
2026-08-20 23:10:12 +08:00
lbl 2e77801adc fix(server): 单台服务器注册失败不再拖垮整个 P2P 栈
问题:data_handle_task_connected 的重连循环中,注册被拒绝
(ResponseMessage::Error)、返回非注册消息、或虚拟网络发生变化时
break 后会调用 task_group.stop(),把包括其他健康服务器连接在内的
整个任务组全部停掉。

修复:
- 注册被拒绝/返回错误消息:与连接失败一致,退避 5 秒后重试,
  仅影响本服务器的重连。
- 虚拟网络发生变化:只结束本服务器的数据任务,不再 stop 整个任务组。

注:重连循环依赖真实服务器连接,未做端到端测试;全部既有单测通过。
2026-08-20 23:08:16 +08:00
lbl 0a73bc3356 fix(nat): TCP/ICMP NAT 不再因单点瞬时错误永久瘫痪
问题:
- tcp_nat listen_task:accept() 或 local/peer_addr 查询失败、
  no_tun 下 network.ip() 未就绪(None),任一错误经 ? 传播直接
  杀掉整个监听任务,NAT 永久失效且无重启。
- icmp_nat task:任一侧 socket 收发或单包处理出错同样经 ? 传播
  终止整个任务。

修复:
- 循环内错误改为 log::warn + continue,并加 100ms 休眠避免持续性
  错误造成空转。
- no_tun 下 network.ip() 为 None 时跳过重写/丢弃该包,而非报错退出。

注:该修复为异步 I/O 循环的容错行为变更,依赖真实 socket,
未补充端到端测试;已有 nat 相关单测全部通过。
2026-08-20 23:05:08 +08:00
lbl 40e2396741 fix(nat): 修复 ICMP NAT 映射只增不减导致内存无界增长
每个 ping 包都会以 (对端地址, identifier, sequence) 为键新增一条
永久映射,运行时间越长内存占用越大。

修复:
- 收到 EchoReply 命中映射即移除该条目(一次 echo 交换完成)。
- 映射值携带创建时间,select 循环中加入 60s 周期的 GC 分支,
  清理超时(60s)未收到应答的残留条目。

测试:test_evict_expired 验证超时条目被清理、未超时条目保留。
2026-08-20 23:00:44 +08:00
lbl d29c9e3122 fix(nat): 修复 UDP NAT 老化回收失效与误删竞态
问题:
1. GC 仅从映射表删除过期条目,其反向转发任务仍阻塞在 socket.recv
   上,任务与 socket 永久残留。
2. 条目过期被 GC 删除并以新 socket 重建后,旧任务退出时无条件
   remove 会把新条目误删。

修复:
- NatEntry 持有反向转发任务的 SubTask 句柄,GC 回收条目时一并
  stop 任务,释放 socket。
- 任务退出时通过 Arc::ptr_eq 确认表中条目仍是本任务的 socket 才删除。

测试:
- test_remove_if_current_only_removes_same_socket: 旧 socket 不能删新条目
- test_entry_inbound_task_stoppable: 条目任务可被 GC 正常终止
2026-08-20 22:58:11 +08:00
lbl af42ff7fe6 refactor(crypto): nonce 与 AAD 职责分离
nonce 只承担唯一性职责:seq(随机起始计数器,现占满 4 字节,
每流约 43 亿包才回绕)+ src + dst;不再用 msg_type 覆盖 seq 低位。

AAD 承担认证职责:msg_type(byte0)/flags(byte2)/reserved(byte3),
即所有传输中不变且不参与 nonce 的头部字节;ttl(byte1) 中继会递减,
仍不认证。

线上协议头部布局不变,但 nonce 推导方式改变,与旧版本
(含 48ba12d)加密不互通。

新增测试 test_tampered_msg_type_rejected:篡改 msg_type 必须解密失败。
2026-08-20 22:54:38 +08:00
lbl 016165ff8e fix(crypto): flags/reserved 头部字节纳入 AAD 认证
chacha20_poly1305 加解密 AAD 为空,flags 字节(COMPRESSED/FEC/GATEWAY)
未经认证,中间人可翻转标志位造成不可检测的丢包/语义篡改。

AAD 取头部 byte2(flags)/byte3(reserved);ttl(byte1) 中继转发时会递减,
不纳入 AAD 以保持中继兼容。

注意:此改动与旧版本(AAD 为空)不兼容,新旧节点间无法互通加密包。

测试:
- test_tampered_flags_rejected: 翻转 flags 必须解密失败
- test_ttl_change_still_decrypts: ttl 递减不影响解密
2026-08-20 22:40:30 +08:00
lbl b78140dc2d 修复 FEC 提交的 clippy 问题
ParityData 重导出改为仅 test 可见;去掉冗余切片与无用 vec!
2026-08-20 22:34:17 +08:00
lbl 1ea38ec715 修复 FEC 变长批次解码必失败且连带丢包
- 编码端把所有数据 shard 填充到 max_len 再做 RS 编码,解码端此前按
  实际长度存放数据 shard,批次内包长不一致时 reconstruct 必报
  IncorrectShardSize。现在记录 shard_size(校验包长度),
  无论校验包先到还是后到都把数据 shard 补齐到统一长度
- 解码失败不再向上传播错误:坏 group 直接移除,
  当前完好包与后续数据包正常透传
- 补充变长批次恢复、校验包先到恢复、解码失败不吞好包三个测试
2026-08-20 22:32:41 +08:00
lbl ca72f2b3d4 修复 Web 状态机在启动中停止时卡死
- 启动任务(含注册重试循环)此前是裸 tokio::spawn,stop 打不断:
  服务器不可达时点击停止,状态永远卡在 Starting 或显示 Running 的死网络。
  现在保存 JoinHandle,stop/restart 时先 abort_start_task 中断它,
  defer 守卫负责把状态迁移到 Stopped
- 清理任务此前 spawn 在自己所等待的 task_group 里,形成自引用等待,
  网络自行停止时永不返回;改为在任务组外等待
- 顺带修复 TaskGroup::wait_all_stopped 的丢失唤醒竞态:
  notified() 先 enable 注册再检查条件
- 补充 Starting 状态停止迁移、任务自然结束唤醒两个测试
2026-08-20 22:21:54 +08:00
lbl bd79844849 修复 Web 静态资源目录穿越
static_handler 此前直接 join 未归一化的 URI 路径,
curl --path-as-is .../../../ 可读取进程权限内任意文件。
改为逐组件校验:仅允许 Normal/CurDir 组件,拒绝 ..、根路径、盘符。
补充路径校验单元测试。
2026-08-20 22:16:01 +08:00
lbl bf2458d464 修复 JNI 建网后 TaskGroupGuard 被立即 drop 导致网络建完即死
TaskGroupGuard drop 会停止整个任务组且后续 spawn 静默失败。此前
nativeCreateNetwork 中 guard 在闭包结束即被释放:注册虽返回成功,
但数据处理任务根本不会启动,VPN 完全不通。

- GlobalState 增加 task_group_guards,guard 随实例持有
- nativeStop 中最后释放 guard;nativeDestroy 整体析构时随之释放
2026-08-20 21:57:33 +08:00
lbl 1ff7405681 修复 register/start_tun 失败后不可重试
- register 拆出 register_impl,Err 时归还 RegistrationContext,
  调用方(CLI/Web 的 5 秒重试循环)不再得到 'can only be called once'
- DeviceIoManager::start_task 改为先创建 tun 设备(唯一可能失败的步骤),
  成功后才消费 TunReceiver/EnhancedOutbound,失败时调用方状态完整可重试
2026-08-20 21:54:27 +08:00
lbl 6489237233 修复空载荷包越界 panic 杀死数据面任务
对端构造的零载荷包(头部长度恰好等于 HEAD_LENGTH)会使
TunDataInbound/InternalNatInbound 的 data[0] 索引越界 panic,
tokio 任务终止后连接永久静默死亡。两处 send 均增加空切片检查,
畸形包静默丢弃。补充空包/短包不 panic 的测试。
2026-08-20 21:48:24 +08:00
lbl b0db6c9659 补充加解密跨版本兼容性测试
nonce 完全由包自带的头部字节计算,解密端不依赖发送端状态,因此
seq 修复与旧版本线上兼容。测试双向验证:旧版本(seq=0)加密的包新版本
可解密,新版本(递增 seq)加密的包按旧逻辑(仅读头部算 nonce)也可解密。
2026-08-20 21:44:50 +08:00
lbl 46d5d24e96 修复 TLS 指纹模式不验握手签名的问题
FingerprintVerifier 此前只比对证书哈希,verify_tls12/13_signature 无条件
放行。而证书由密码确定性生成、属于公开信息,主动中间人重放真实证书即可
完成握手,指纹模式无法抵抗 MITM。

- verify_tls12/13_signature 改为调用 rustls::crypto::verify_tls12/13_signature
  做真实验签(ring provider 的签名算法集)
- supported_verify_schemes 返回算法集实际支持的方案
- 补充基于 tokio 内存通道的真实 TLS 握手测试:指纹匹配握手成功、
  指纹不匹配握手失败
2026-08-20 21:41:02 +08:00
lbl 3c0532b4c5 修复加密 nonce 全局重用
PacketCrypto 的 nonce 由 msg_type+seq+src+dst 构成,但 seq 字段从未赋值,
同一 (msg_type,src,dst) 的所有包共用同一 nonce,AEAD 密钥流重用。

- PacketCrypto 增加 Arc<AtomicU32> 出站序号计数器(随机起始值,避免
  进程重启后相同密钥复用低序号段),encrypt_in_place 时自动分配 seq
- 补充 nonce 唯一性与 clone 共享计数器的测试
2026-08-20 21:33:37 +08:00
lbl 216cf16310 修复中继路由探测失效
- MsgType 的 TryFrom 与枚举判别值不一致:RelayProbe 写 18 却被解码为
  RelayProbeReply,真正的 Reply(19) 被当非法类型丢弃,改为 18/19 与枚举一致
- RelayProbeReply 回复 TTL 由 1 改为 2:TTL=1 时中继节点 decr 后即被丢弃,
  回复永远到不了发起方,中继路由(metric=2)无法建立
- 补充 MsgType 全变体 round-trip 与中继 TTL 语义测试
2026-08-20 21:30:32 +08:00
lbl 7d481560e3 规范依赖管理并适配 rand 0.10
- tcp_ip 从 git 依赖切换为 crates.io 0.2(IpStackConfig 改用 builder)
- 公共依赖收拢到 workspace.dependencies 统一版本
- 移除 release profile 的 panic=abort(避免 vnt-jni 中止宿主 JVM)
- vnt-jni 升级 edition 2024(no_mangle 改为 unsafe(no_mangle))
- vnt-ipc 显式声明 tokio features
- 适配 rand 0.10:random_range 移至 RngExt,choose_multiple 改名 sample
- 修复 clippy 警告(type_complexity/collapsible_if/ptr_arg)
2026-08-20 20:58:07 +08:00
lbl e088d305b1 移除过期统计值 2026-03-02 15:52:24 +08:00
lbl 39602c478e 调整路径探测协议 2026-03-02 14:23:39 +08:00
lbl 1dc53ca898 计算路由评分 2026-02-28 19:26:19 +08:00
lbl 12f7eed8a5 记录具体路径的丢包率 2026-02-28 18:56:11 +08:00
lbl 2aa423062e 支持设置端口 2026-02-28 17:49:11 +08:00
lbl 5a31ad89f4 jni 2026-02-24 18:31:40 +08:00
lbl ecdb359bbd fmt 2026-02-24 16:08:44 +08:00
lbl 4708ed2b16 注册失败时重试 2026-02-24 16:08:18 +08:00
153 changed files with 18554 additions and 3737 deletions
@@ -0,0 +1,218 @@
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const START_MARKER = "<!-- vnt-downloads:start -->";
const END_MARKER = "<!-- vnt-downloads:end -->";
const TOOL_TARGETS = [
["Windows", "x86_64", "x86_64-pc-windows-msvc"],
["Windows", "x86", "i686-pc-windows-msvc"],
["Windows", "ARM64", "aarch64-pc-windows-msvc"],
["Linux", "x86_64", "x86_64-unknown-linux-musl"],
["Linux", "ARM64", "aarch64-unknown-linux-musl"],
["Linux", "ARMv7 hard-float", "armv7-unknown-linux-musleabihf"],
["Linux", "ARMv7 soft-float", "armv7-unknown-linux-musleabi"],
["Linux", "ARM hard-float", "arm-unknown-linux-musleabihf"],
["Linux", "ARM soft-float", "arm-unknown-linux-musleabi"],
["Linux", "MIPS little-endian", "mipsel-unknown-linux-musl"],
["Linux", "MIPS big-endian", "mips-unknown-linux-musl"],
["macOS", "Apple Silicon", "aarch64-apple-darwin"],
["macOS", "Intel", "x86_64-apple-darwin"],
["FreeBSD 13.2", "x86_64", "x86_64-unknown-freebsd"],
];
const DOWNLOADS = [
{
product: "VNT Desktop",
platform: "Windows",
architecture: "x86_64",
format: "EXE 安装包",
filename: ({ version }) => `VNT.Desktop_${version}_windows_x64-setup.exe`,
},
{
product: "VNT Desktop",
platform: "Windows",
architecture: "x86_64",
format: "MSI 安装包",
filename: ({ version }) => `VNT.Desktop_${version}_windows_x64.msi`,
},
...TOOL_TARGETS.map(([platform, architecture, target]) => ({
product: "VNT 工具包",
platform,
architecture,
format: "ZIP",
filename: ({ tag }) => `vnt2-${target}-${tag}.zip`,
})),
];
export function buildDownloadSection({ version, tag, releaseUrl, assetNames }) {
const rows = DOWNLOADS.flatMap((download) => {
const filename = download.filename({ version, tag });
if (!assetNames.has(filename)) return [];
const url = `${releaseUrl}/${encodeURIComponent(filename)}`;
return [
`| ${download.product} | ${download.platform} | ${download.architecture} | [${download.format}](${url}) |`,
];
});
if (rows.length === 0) return undefined;
return [
START_MARKER,
"## 下载",
"",
"> VNT 工具包包含 `vnt2_web`、`vnt2_cli` 和 `vnt2_ctrl`。",
"",
"| 产品 | 平台 | 架构 | 下载 |",
"|---|---|---|---|",
...rows,
END_MARKER,
].join("\n");
}
export function mergeDownloadSection(notes, section) {
const start = notes.indexOf(START_MARKER);
if (start === -1) {
const existing = notes.trimEnd();
return existing.length === 0 ? `${section}\n` : `${existing}\n\n${section}\n`;
}
const end = notes.indexOf(END_MARKER, start);
if (end === -1) {
throw new Error("release notes contain an incomplete download section");
}
return `${notes.slice(0, start)}${section}${notes.slice(end + END_MARKER.length)}`;
}
function releaseAssetId(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return undefined;
}
if (parsed.hostname !== "api.github.com") return undefined;
return parsed.pathname.match(/\/releases\/assets\/(\d+)$/)?.[1];
}
export function normalizeUpdaterManifest(manifest, assets) {
const downloadUrls = new Map(
assets.map((asset) => [String(asset.id), asset.browser_download_url]),
);
let changed = false;
const platforms = Object.fromEntries(
Object.entries(manifest.platforms || {}).map(([target, platform]) => {
const assetId = releaseAssetId(platform.url);
if (!assetId) return [target, platform];
const downloadUrl = downloadUrls.get(assetId);
if (!downloadUrl) {
throw new Error(
`updater platform ${target} references unknown release asset ${assetId}`,
);
}
changed = true;
return [target, { ...platform, url: downloadUrl }];
}),
);
return {
changed,
manifest: changed ? { ...manifest, platforms } : manifest,
};
}
function normalizeReleaseUpdaterManifest({ repository, tag, release }) {
const latestAsset = release.assets.find((asset) => asset.name === "latest.json");
if (!latestAsset) throw new Error(`release ${tag} does not contain latest.json`);
const manifest = JSON.parse(
execFileSync(
"gh",
[
"api",
"-H",
"Accept: application/octet-stream",
`repos/${repository}/releases/assets/${latestAsset.id}`,
],
{ encoding: "utf8", env: process.env, windowsHide: true },
),
);
const normalized = normalizeUpdaterManifest(manifest, release.assets);
if (!normalized.changed) return;
const manifestDir = mkdtempSync(join(tmpdir(), "vnt-updater-manifest-"));
const manifestPath = join(manifestDir, "latest.json");
try {
writeFileSync(manifestPath, `${JSON.stringify(normalized.manifest, null, 2)}\n`);
execFileSync(
"gh",
["release", "upload", tag, manifestPath, "--repo", repository, "--clobber"],
{ stdio: "inherit", env: process.env, windowsHide: true },
);
} finally {
rmSync(manifestDir, { recursive: true, force: true });
}
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function main() {
const repository = requiredEnv("GITHUB_REPOSITORY");
const tag = requiredEnv("GITHUB_REF_NAME");
const serverUrl = requiredEnv("GITHUB_SERVER_URL").replace(/\/$/, "");
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const configPath = resolve(workspace, "vnt-desktop/src-tauri/tauri.conf.json");
const version = JSON.parse(readFileSync(configPath, "utf8")).version;
const release = JSON.parse(
execFileSync(
"gh",
["api", `repos/${repository}/releases/tags/${tag}`],
{ encoding: "utf8", env: process.env, windowsHide: true },
),
);
normalizeReleaseUpdaterManifest({ repository, tag, release });
const assetNames = new Set(release.assets.map((asset) => asset.name));
const releaseUrl = `${serverUrl}/${repository}/releases/download/${tag}`;
const section = buildDownloadSection({ version, tag, releaseUrl, assetNames });
if (!section) throw new Error(`release ${tag} does not contain recognized assets`);
const notes = release.body || "";
const updatedNotes = mergeDownloadSection(notes, section);
if (updatedNotes === notes) return;
const notesFile = join(
process.env.RUNNER_TEMP || tmpdir(),
`vnt-release-notes-${process.pid}.md`,
);
try {
writeFileSync(notesFile, updatedNotes);
execFileSync(
"gh",
["release", "edit", tag, "--repo", repository, "--notes-file", notesFile],
{ stdio: "inherit", env: process.env, windowsHide: true },
);
} finally {
if (existsSync(notesFile)) unlinkSync(notesFile);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main();
}
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildDownloadSection,
mergeDownloadSection,
normalizeUpdaterManifest,
} from "./update-release-downloads.mjs";
test("builds links for desktop installers and target tool bundles", () => {
const section = buildDownloadSection({
version: "2.0.0",
tag: "v2.0.0",
releaseUrl: "https://github.com/vnt-dev/vnt/releases/download/v2.0.0",
assetNames: new Set([
"VNT.Desktop_2.0.0_windows_x64-setup.exe",
"vnt2-x86_64-unknown-linux-musl-v2.0.0.zip",
]),
});
assert.match(section, /VNT\.Desktop_2\.0\.0_windows_x64-setup\.exe/);
assert.match(section, /vnt2-x86_64-unknown-linux-musl-v2\.0\.0\.zip/);
assert.doesNotMatch(section, /VNT\.Desktop_2\.0\.0_windows_x64\.msi/);
});
test("replaces an existing release download section", () => {
const oldSection = [
"<!-- vnt-downloads:start -->",
"old links",
"<!-- vnt-downloads:end -->",
].join("\n");
const merged = mergeDownloadSection(`Release notes\n\n${oldSection}\n`, "new links");
assert.equal(merged, "Release notes\n\nnew links\n");
});
test("replaces GitHub API updater URLs with public download URLs", () => {
const manifest = {
version: "2.0.0",
platforms: {
"windows-x86_64": {
signature: "windows-signature",
url: "https://api.github.com/repos/vnt-dev/vnt/releases/assets/101",
},
},
};
const assets = [
{
id: 101,
browser_download_url:
"https://github.com/vnt-dev/vnt/releases/download/v2.0.0/VNT.exe",
},
];
const normalized = normalizeUpdaterManifest(manifest, assets);
assert.equal(normalized.changed, true);
assert.equal(
normalized.manifest.platforms["windows-x86_64"].url,
assets[0].browser_download_url,
);
assert.equal(
normalized.manifest.platforms["windows-x86_64"].signature,
"windows-signature",
);
});
test("rejects updater URLs for unknown release assets", () => {
assert.throws(
() =>
normalizeUpdaterManifest(
{
platforms: {
"windows-x86_64": {
signature: "signature",
url: "https://api.github.com/repos/vnt-dev/vnt/releases/assets/999",
},
},
},
[],
),
/unknown release asset 999/,
);
});
+82
View File
@@ -0,0 +1,82 @@
name: PR Checks
on:
pull_request:
permissions:
contents: read
concurrency:
group: pr-checks-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
frontend:
name: Frontend checks
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.34.5
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build shared Web frontend
run: pnpm build:web
- name: Build Tauri frontend
run: pnpm build:desktop-ui
- name: Test release metadata scripts
run: node --test .github/scripts/*.test.mjs
rust:
name: ${{ matrix.name }} Rust checks
strategy:
fail-fast: false
matrix:
include:
- name: Windows
os: windows-latest
- name: Linux
os: ubuntu-22.04
- name: macOS
os: macos-latest
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Check workspace
run: cargo check --workspace --all-targets
- name: Run Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run tests
run: cargo test --workspace --all-targets
+200 -7
View File
@@ -1,4 +1,4 @@
name: Rust
name: Release
on:
push:
@@ -17,8 +17,36 @@ permissions:
packages: write
jobs:
build-web:
name: Build Web frontend
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.34.5
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build shared Web frontend
run: pnpm build:web
- name: Upload Web static files
uses: actions/upload-artifact@v4
with:
name: vnt-web-static
path: vnt-web/static
retention-days: 1
build:
name: Build ${{ matrix.TARGET }}
needs: build-web
strategy:
fail-fast: false
matrix:
@@ -51,16 +79,22 @@ jobs:
OS: ubuntu-latest
- TARGET: x86_64-unknown-freebsd
OS: ubuntu-latest
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
ARTIFACT_NAME: freebsd-14.4-x86_64
BSD_VERSION: '14.4'
runs-on: ${{ matrix.OS }}
env:
NAME: Vnt
TARGET: ${{ matrix.TARGET }}
OS: ${{ matrix.OS }}
FEATURES: ${{ matrix.FEATURES }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Replace Web static files with current frontend build
run: rm -rf vnt-web/static && mkdir -p vnt-web/static
- name: Download Web static files
uses: actions/download-artifact@v4
with:
name: vnt-web-static
path: vnt-web/static
- name: Init submodules
run: git submodule update --init --recursive --remote && git submodule status
- name: Cargo cache
@@ -82,7 +116,7 @@ jobs:
- name: List
run: find ./
- name: Build Vnt X86_64-FreeBSD
uses: cross-platform-actions/action@v0.23.0
uses: cross-platform-actions/action@v1.3.0
if: ${{ endsWith(matrix.TARGET, 'freebsd') }}
env:
TARGET: ${{ matrix.TARGET }}
@@ -260,10 +294,103 @@ jobs:
name: Vnt-${{ matrix.TARGET }}
path: |
./artifacts/*
build-android-jni:
name: Build Android JNI (${{ matrix.abi }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-linux-android
abi: arm64-v8a
clang: aarch64-linux-android24-clang
cargo_env: AARCH64_LINUX_ANDROID
cc_env: CC_aarch64_linux_android
ar_env: AR_aarch64_linux_android
- target: armv7-linux-androideabi
abi: armeabi-v7a
clang: armv7a-linux-androideabi24-clang
cargo_env: ARMV7_LINUX_ANDROIDEABI
cc_env: CC_armv7_linux_androideabi
ar_env: AR_armv7_linux_androideabi
- target: x86_64-linux-android
abi: x86_64
clang: x86_64-linux-android24-clang
cargo_env: X86_64_LINUX_ANDROID
cc_env: CC_x86_64_linux_android
ar_env: AR_x86_64_linux_android
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install Rust target
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Install Android NDK r27d
run: sdkmanager "ndk;27.3.13750724"
- name: Cache Cargo dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: android-jni-${{ matrix.target }}-${{ hashFiles('Cargo.lock') }}
restore-keys: android-jni-${{ matrix.target }}-
- name: Build vnt-jni
env:
TARGET: ${{ matrix.target }}
CLANG: ${{ matrix.clang }}
CARGO_ENV: ${{ matrix.cargo_env }}
CC_ENV: ${{ matrix.cc_env }}
AR_ENV: ${{ matrix.ar_env }}
run: |
set -euo pipefail
NDK_HOME="$ANDROID_SDK_ROOT/ndk/27.3.13750724"
toolchain="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
export PATH="$toolchain:$PATH"
export "CARGO_TARGET_${CARGO_ENV}_LINKER=$toolchain/$CLANG"
export "CARGO_TARGET_${CARGO_ENV}_RUSTFLAGS=-C link-arg=-Wl,-z,max-page-size=16384"
export "$CC_ENV=$toolchain/$CLANG"
export "$AR_ENV=$toolchain/llvm-ar"
cargo build --locked --release -p vnt-jni --target "$TARGET"
- name: Verify and package native library
env:
TARGET: ${{ matrix.target }}
ABI: ${{ matrix.abi }}
run: |
set -euo pipefail
NDK_HOME="$ANDROID_SDK_ROOT/ndk/27.3.13750724"
library="target/$TARGET/release/libvnt_jni.so"
readelf="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf"
nm="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
test -s "$library"
"$nm" -D --defined-only "$library" | grep -q Java_com_vnt_VntManager_nativeInit
"$readelf" -l "$library" | awk '/ LOAD / { found=1; if ($NF != "0x4000") bad=1 } END { exit (!found || bad) }'
mkdir -p artifacts
asset="artifacts/vnt-jni-${ABI}-${GITHUB_REF_NAME}.so"
cp "$library" "$asset"
(cd artifacts && sha256sum "$(basename "$asset")" > "$(basename "$asset").sha256")
- name: Upload Android JNI artifact
uses: actions/upload-artifact@v4
with:
name: Vnt-Android-${{ matrix.abi }}
path: artifacts/vnt-jni-${{ matrix.abi }}-${{ github.ref_name }}.so*
if-no-files-found: error
# deploys to github releases on tag
deploy:
if: startsWith(github.ref, 'refs/tags/')
needs: build
needs: [build, build-android-jni]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
@@ -280,3 +407,69 @@ jobs:
tag: ${{ github.ref }}
overwrite: true
file_glob: true
- name: Release Android JNI libraries
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./artifacts/Vnt-Android-*/*.so*
tag: ${{ github.ref }}
overwrite: true
file_glob: true
desktop-windows:
name: Build and publish VNT Desktop for Windows
if: startsWith(github.ref, 'refs/tags/')
needs: deploy
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.34.5
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Verify tag matches desktop version
shell: pwsh
run: |
$config = Get-Content vnt-desktop/src-tauri/tauri.conf.json | ConvertFrom-Json
$tagVersion = "${{ github.ref_name }}" -replace '^v', ''
if ($tagVersion -ne $config.version) {
throw "Tag ${{ github.ref_name }} does not match desktop version $($config.version)"
}
- name: Build and publish Windows desktop installers
uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: vnt-desktop
tagName: ${{ github.ref_name }}
releaseName: VNT ${{ github.ref_name }}
releaseDraft: false
prerelease: false
releaseAssetNamePattern: 'VNT.Desktop_[version]_windows_[arch][setup][ext]'
args: --bundles nsis,msi
release-notes:
name: Add download links to release notes
if: startsWith(github.ref, 'refs/tags/')
needs: desktop-windows
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Update updater metadata and release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/update-release-downloads.mjs
+11
View File
@@ -1,5 +1,16 @@
.idea
/target
./wintun.dll
vnt_config
vnt_current_config.txt
logs/*
/.tools/
node_modules
vnt-web/ui/dist
vnt-desktop/dist
vnt-desktop/src-tauri/gen
vnt-desktop/src-tauri/icons/android
vnt-desktop/src-tauri/icons/ios
# 前端构建产物,由 vnt-web/build.rs 自动构建
vnt-web/static
+5
View File
@@ -0,0 +1,5 @@
/node_modules/
/vnt-desktop/node_modules/
/vnt-web/ui/node_modules/
/vnt-desktop/dist/
/vnt-web/static/
Generated
+3866 -119
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
@@ -22,6 +28,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -87,6 +108,15 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.8.1"
@@ -120,7 +150,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
"synstructure",
]
@@ -132,7 +162,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -169,6 +199,38 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -180,6 +242,35 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "async-scoped"
version = "0.9.0"
@@ -191,6 +282,24 @@ dependencies = [
"tokio",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -205,7 +314,30 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
name = "atk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib",
"libc",
]
[[package]]
name = "atk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
@@ -272,12 +404,33 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -289,6 +442,9 @@ name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
dependencies = [
"serde_core",
]
[[package]]
name = "block-buffer"
@@ -299,6 +455,24 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
@@ -312,12 +486,48 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli"
version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -329,6 +539,9 @@ name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [
"serde",
]
[[package]]
name = "c2rust-bitfields"
@@ -347,7 +560,74 @@ checksum = "3b457277798202ccd365b9c112ebee08ddd57f1033916c8b8ea52f222e5b715d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.10.0",
"cairo-sys-rs",
"glib",
"libc",
"once_cell",
"thiserror 1.0.69",
]
[[package]]
name = "cairo-sys-rs"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "camino"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
"serde",
]
[[package]]
name = "cargo_metadata"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "cargo_toml"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
dependencies = [
"serde",
"toml 0.9.11+spec-1.1.0",
]
[[package]]
@@ -360,6 +640,33 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfb"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
]
[[package]]
name = "cfg-expr"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -372,6 +679,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.43"
@@ -380,6 +698,7 @@ checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link 0.2.1",
]
@@ -411,10 +730,10 @@ version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -443,7 +762,7 @@ checksum = "9f7c1b60bae2c3d45228dfb096046aa51ef6c300de70b658d7a13fcb0c4f832e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -452,6 +771,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -474,6 +803,22 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -500,6 +845,30 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -509,6 +878,33 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -534,6 +930,38 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.114",
]
[[package]]
name = "csv"
version = "1.4.0"
@@ -555,6 +983,56 @@ dependencies = [
"memchr",
]
[[package]]
name = "ctor"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
dependencies = [
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn 2.0.114",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -575,6 +1053,48 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dbus"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "der-parser"
version = "10.0.0"
@@ -596,6 +1116,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
@@ -616,7 +1148,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn",
"syn 2.0.114",
"unicode-xid",
]
@@ -642,8 +1174,52 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
]
[[package]]
@@ -654,7 +1230,30 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
dependencies = [
"dlopen2_derive",
"libc",
"once_cell",
"winapi",
]
[[package]]
name = "dlopen2_derive"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
@@ -667,6 +1266,66 @@ dependencies = [
"quick-error",
]
[[package]]
name = "dom_query"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser",
"foldhash 0.2.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "dpi"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
dependencies = [
"serde",
]
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "dtor"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -679,6 +1338,26 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.0.6+spec-1.1.0",
"vswhom",
"winreg",
]
[[package]]
name = "embed_plist"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -694,12 +1373,50 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -740,6 +1457,35 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -753,15 +1499,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flume"
version = "0.11.1"
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"futures-core",
"futures-sink",
"nanorand",
"spin",
"crc32fast",
"miniz_oxide",
]
[[package]]
@@ -788,6 +1532,39 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
]
[[package]]
name = "foreign-types-macros"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -851,7 +1628,10 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
@@ -863,7 +1643,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -896,6 +1676,105 @@ dependencies = [
"slab",
]
[[package]]
name = "gdk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk-pixbuf"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
"once_cell",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]]
name = "gdkwayland-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
dependencies = [
"gdk-sys",
"glib-sys",
"gobject-sys",
"libc",
"pkg-config",
"system-deps",
]
[[package]]
name = "gdkx11"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
dependencies = [
"gdk",
"gdkx11-sys",
"gio",
"glib",
"libc",
"x11",
]
[[package]]
name = "gdkx11-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
dependencies = [
"gdk-sys",
"glib-sys",
"libc",
"system-deps",
"x11",
]
[[package]]
name = "generator"
version = "0.7.5"
@@ -952,17 +1831,171 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
]
[[package]]
name = "gio"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"once_cell",
"pin-project-lite",
"smallvec",
"thiserror 1.0.69",
]
[[package]]
name = "gio-sys"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"winapi",
]
[[package]]
name = "glib"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.10.0",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"once_cell",
"smallvec",
"thiserror 1.0.69",
]
[[package]]
name = "glib-macros"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
dependencies = [
"heck 0.4.1",
"proc-macro-crate 2.0.2",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gobject-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "gtk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
dependencies = [
"atk",
"cairo-rs",
"field-offset",
"futures-channel",
"gdk",
"gdk-pixbuf",
"gio",
"glib",
"gtk-sys",
"gtk3-macros",
"libc",
"pango",
"pkg-config",
]
[[package]]
name = "gtk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
dependencies = [
"atk-sys",
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk3-macros"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -984,7 +2017,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -993,12 +2026,24 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1016,6 +2061,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "html5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
dependencies = [
"log",
"markup5ever",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -1049,12 +2104,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1073,6 +2122,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.8.1"
@@ -1092,6 +2150,22 @@ dependencies = [
"pin-utils",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
@@ -1100,13 +2174,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@@ -1133,6 +2215,137 @@ dependencies = [
"cc",
]
[[package]]
name = "ico"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png 0.17.16",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -1141,6 +2354,17 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "infer"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
dependencies = [
"cfb",
]
[[package]]
@@ -1161,6 +2385,25 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1182,6 +2425,153 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "javascriptcore-rs"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
dependencies = [
"bitflags 1.3.2",
"glib",
"javascriptcore-rs-sys",
]
[[package]]
name = "javascriptcore-rs-sys"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link 0.2.1",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.0",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.114",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.114",
]
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -1192,18 +2582,94 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "json-patch"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
dependencies = [
"jsonptr",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.10.0",
"serde",
"unicode-segmentation",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libappindicator"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
dependencies = [
"glib",
"gtk",
"gtk-sys",
"libappindicator-sys",
"log",
]
[[package]]
name = "libappindicator-sys"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
"libloading 0.7.4",
"once_cell",
]
[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
dependencies = [
"cfg-if",
"winapi",
]
[[package]]
name = "libloading"
version = "0.9.0"
@@ -1220,12 +2686,27 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1267,7 +2748,7 @@ dependencies = [
"log-mdc",
"mock_instant",
"parking_lot 0.12.5",
"rand",
"rand 0.9.2",
"serde",
"serde-value",
"serde_json",
@@ -1309,24 +2790,35 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lz4_flex"
version = "0.12.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e"
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
dependencies = [
"twox-hash",
]
[[package]]
name = "machine-uid"
version = "0.5.4"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d7217d573cdb141d6da43113b098172e057d39915d79c4bdedbc3aacd46bd96"
checksum = "cfe0d6336d341b10ae80e099b8f3c59f34bf8aef66ef25e83aa98bf2955c0ae7"
dependencies = [
"libc",
"windows-registry",
"windows-sys 0.61.2",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -1379,6 +2871,22 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -1396,6 +2904,27 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6"
[[package]]
name = "muda"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
dependencies = [
"crossbeam-channel",
"dpi",
"gtk",
"keyboard-types",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
]
[[package]]
name = "multimap"
version = "0.10.1"
@@ -1403,12 +2932,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "nanorand"
version = "0.7.0"
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"getrandom 0.2.17",
"bitflags 2.10.0",
"jni-sys 0.3.0",
"log",
"ndk-sys",
"num_enum",
"raw-window-handle",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.0",
]
[[package]]
@@ -1502,6 +3046,12 @@ dependencies = [
"log",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
@@ -1603,10 +3153,10 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -1618,6 +3168,214 @@ dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
"objc2-exception-helper",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.10.0",
"block2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-exception-helper"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
dependencies = [
"cc",
]
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.10.0",
"block2",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-web-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.10.0",
"block2",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -1639,12 +3397,29 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "2.10.1"
@@ -1654,6 +3429,55 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
dependencies = [
"gio",
"glib",
"libc",
"once_cell",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -1720,7 +3544,7 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"base64 0.22.1",
"serde_core",
]
@@ -1738,7 +3562,60 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
"indexmap 2.13.0",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
@@ -1758,7 +3635,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -1784,6 +3661,25 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "plist"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "pnet_base"
version = "0.35.0"
@@ -1802,7 +3698,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -1826,6 +3722,70 @@ dependencies = [
"pnet_macros_support",
]
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.10.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -1841,6 +3801,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -1848,7 +3814,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
"syn 2.0.114",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[package]]
name = "proc-macro-crate"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
dependencies = [
"toml_datetime 0.6.3",
"toml_edit 0.20.2",
]
[[package]]
@@ -1857,7 +3843,31 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit",
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
@@ -1885,7 +3895,7 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
"heck",
"heck 0.5.0",
"itertools",
"log",
"multimap",
@@ -1894,7 +3904,7 @@ dependencies = [
"prost",
"prost-types",
"regex",
"syn",
"syn 2.0.114",
"tempfile",
]
@@ -1908,7 +3918,7 @@ dependencies = [
"itertools",
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -1920,12 +3930,85 @@ dependencies = [
"prost",
]
[[package]]
name = "protoc-bin-vendored"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-s390_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-aarch_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
[[package]]
name = "protoc-bin-vendored-linux-s390_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
[[package]]
name = "protoc-bin-vendored-macos-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1955,7 +4038,7 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -1996,6 +4079,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "r-ex"
version = "1.0.1"
@@ -2009,7 +4098,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -2019,7 +4119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@@ -2031,6 +4131,18 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rcgen"
version = "0.14.7"
@@ -2063,6 +4175,17 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.18",
]
[[package]]
name = "reed-solomon-erasure"
version = "6.0.0"
@@ -2076,6 +4199,26 @@ dependencies = [
"spin",
]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "regex"
version = "1.12.3"
@@ -2105,6 +4248,45 @@ version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
[[package]]
name = "reqwest"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -2125,7 +4307,7 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "319bb478ff9aae1dc7a544fa599e9eb47e2521621dc3921fba6abcaaa312092c"
dependencies = [
"flume 0.12.0",
"flume",
"libc",
"netlink-packet-core 0.8.1",
"netlink-packet-route 0.28.0",
@@ -2153,7 +4335,7 @@ dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"syn 2.0.114",
"walkdir",
]
@@ -2163,7 +4345,7 @@ version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"sha2 0.10.9",
"walkdir",
]
@@ -2186,7 +4368,7 @@ dependencies = [
"libc",
"log",
"parking_lot 0.12.5",
"rand",
"rand 0.9.2",
"serde",
"socket2 0.5.10",
"stun-format",
@@ -2268,6 +4450,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
@@ -2309,6 +4518,57 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
"schemars_derive",
"serde",
"serde_json",
"url",
"uuid",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.114",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -2344,11 +4604,34 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.10.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
@@ -2360,6 +4643,18 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
dependencies = [
"erased-serde",
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "serde-value"
version = "0.7.0"
@@ -2387,7 +4682,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
@@ -2414,6 +4720,26 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
@@ -2435,13 +4761,46 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.13.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"indexmap 2.13.0",
"itoa",
"ryu",
"serde",
@@ -2449,14 +4808,45 @@ dependencies = [
]
[[package]]
name = "sha1"
version = "0.10.6"
name = "serialize-to-javascript"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
dependencies = [
"serde",
"serde_json",
"serialize-to-javascript-impl",
]
[[package]]
name = "serialize-to-javascript-impl"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -2466,8 +4856,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -2495,6 +4896,34 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
@@ -2527,6 +4956,54 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
dependencies = [
"bytemuck",
"js-sys",
"ndk",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "soup3"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
dependencies = [
"futures-channel",
"gio",
"glib",
"libc",
"soup3-sys",
]
[[package]]
name = "soup3-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "spin"
version = "0.9.8"
@@ -2536,6 +5013,36 @@ dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot 0.12.5",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -2567,6 +5074,27 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e"
dependencies = [
"base64 0.21.7",
"serde",
"serde_json",
]
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.114"
@@ -2578,11 +5106,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -2592,7 +5134,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -2605,6 +5147,19 @@ dependencies = [
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 0.8.2",
"version-compare",
]
[[package]]
name = "tachyonix"
version = "0.3.1"
@@ -2618,19 +5173,398 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "tao"
version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.10.0",
"block2",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
"dispatch2",
"dlopen2",
"dpi",
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni 0.21.1",
"libc",
"log",
"ndk",
"ndk-sys",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-ui-kit",
"once_cell",
"parking_lot 0.12.5",
"percent-encoding",
"raw-window-handle",
"tao-macros",
"unicode-segmentation",
"url",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
]
[[package]]
name = "tao-macros"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dunce",
"embed_plist",
"getrandom 0.3.4",
"glob",
"gtk",
"heck 0.5.0",
"http",
"jni 0.21.1",
"libc",
"log",
"mime",
"muda",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-ui-kit",
"objc2-web-kit",
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"serde",
"serde_json",
"serde_repr",
"serialize-to-javascript",
"swift-rs",
"tauri-build",
"tauri-macros",
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon",
"url",
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows 0.61.3",
]
[[package]]
name = "tauri-build"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"tauri-utils",
"tauri-winres",
"walkdir",
]
[[package]]
name = "tauri-codegen"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
dependencies = [
"base64 0.22.1",
"brotli",
"ico",
"json-patch",
"plist",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"syn 2.0.114",
"tauri-utils",
"thiserror 2.0.18",
"time",
"url",
"uuid",
"walkdir",
]
[[package]]
name = "tauri-macros"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"tauri-codegen",
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows 0.61.3",
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8"
dependencies = [
"cookie",
"dpi",
"gtk",
"http",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
"raw-window-handle",
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.18",
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
]
[[package]]
name = "tauri-runtime-wry"
version = "2.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
"once_cell",
"percent-encoding",
"raw-window-handle",
"softbuffer",
"tao",
"tauri-runtime",
"tauri-utils",
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"wry",
]
[[package]]
name = "tauri-utils"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
dependencies = [
"anyhow",
"brotli",
"cargo_metadata",
"ctor",
"dom_query",
"dunce",
"glob",
"http",
"infer",
"json-patch",
"log",
"memchr",
"phf",
"plist",
"proc-macro2",
"quote",
"regex",
"schemars 0.8.22",
"semver",
"serde",
"serde-untagged",
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 1.0.6+spec-1.1.0",
"url",
"urlpattern",
"uuid",
"walkdir",
]
[[package]]
name = "tauri-winres"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [
"dunce",
"embed-resource",
"toml 1.0.6+spec-1.1.0",
]
[[package]]
name = "tcp_ip"
version = "0.1.10"
source = "git+https://github.com/rustp2p/tcp_ip#09c64c81da70a35248dfef4ef42bb9f5e8b6e05e"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3b3309aae4733df008397c448ddeff5d54dfbcc6c19950b43272cef668f0fe"
dependencies = [
"bytes",
"dashmap",
"flume 0.11.1",
"flume",
"log",
"num_enum",
"parking_lot 0.12.5",
"pnet_packet",
"rand",
"rand 0.10.2",
"tokio",
"tokio-util",
]
@@ -2648,6 +5582,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -2683,7 +5626,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -2694,7 +5637,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -2749,6 +5692,16 @@ dependencies = [
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
@@ -2789,7 +5742,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -2804,9 +5757,9 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
dependencies = [
"futures-util",
"log",
@@ -2827,19 +5780,55 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
"toml_edit 0.20.2",
]
[[package]]
name = "toml"
version = "0.9.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
dependencies = [
"indexmap",
"indexmap 2.13.0",
"serde_core",
"serde_spanned",
"toml_datetime",
"serde_spanned 1.0.4",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
"winnow 0.7.14",
]
[[package]]
name = "toml"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.0.4",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.14",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
dependencies = [
"serde",
]
[[package]]
@@ -2851,25 +5840,58 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap",
"toml_datetime",
"indexmap 2.13.0",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
"winnow 0.7.14",
]
[[package]]
name = "toml_parser"
version = "1.0.6+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
"winnow 1.0.4",
]
[[package]]
@@ -2896,28 +5918,35 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.8"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.10.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-http"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"bitflags 2.10.0",
"bytes",
"http",
"percent-encoding",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -2952,7 +5981,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -2994,6 +6023,34 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "tray-icon"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
dependencies = [
"crossbeam-channel",
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tun-rs"
version = "2.8.1"
@@ -3010,7 +6067,7 @@ dependencies = [
"getifaddrs",
"ipnet",
"libc",
"libloading",
"libloading 0.9.0",
"log",
"netconfig-rs",
"nix 0.31.1",
@@ -3024,19 +6081,18 @@ dependencies = [
[[package]]
name = "tungstenite"
version = "0.28.0"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"rand 0.10.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
@@ -3045,6 +6101,12 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typemap-ors"
version = "1.0.0"
@@ -3056,9 +6118,61 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
dependencies = [
"unic-char-range",
]
[[package]]
name = "unic-char-range"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
[[package]]
name = "unic-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
[[package]]
name = "unic-ucd-ident"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
dependencies = [
"unic-char-property",
"unic-char-range",
"unic-ucd-version",
]
[[package]]
name = "unic-ucd-version"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
@@ -3112,10 +6226,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "utf-8"
version = "0.7.6"
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
name = "urlpattern"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
dependencies = [
"regex",
"serde",
"unic-ucd-ident",
"url",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
@@ -3131,6 +6270,7 @@ checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde_core",
"wasm-bindgen",
]
@@ -3140,6 +6280,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3148,7 +6294,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vnt-core"
version = "2.0.0"
version = "2.0.2"
dependencies = [
"anyhow",
"bytes",
@@ -3157,6 +6303,7 @@ dependencies = [
"getifaddrs",
"hex",
"ipnet",
"libc",
"log",
"lz4_flex",
"machine-uid",
@@ -3164,8 +6311,9 @@ dependencies = [
"pnet_packet",
"prost",
"prost-build",
"protoc-bin-vendored",
"quinn",
"rand",
"rand 0.10.2",
"rcgen",
"reed-solomon-erasure",
"ring",
@@ -3173,7 +6321,7 @@ dependencies = [
"rustls",
"rustls-native-certs",
"serde",
"sha2",
"sha2 0.11.0",
"socket2 0.6.2",
"tcp_ip",
"time",
@@ -3186,12 +6334,34 @@ dependencies = [
"uuid",
"widestring",
"winapi",
"windows-sys 0.61.2",
"zerocopy",
]
[[package]]
name = "vnt-desktop"
version = "2.0.2"
dependencies = [
"anyhow",
"log",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"vnt-web",
"vnt2",
]
[[package]]
name = "vnt-ipc"
version = "2.0.0"
version = "2.0.2"
dependencies = [
"anyhow",
"cli-table",
@@ -3200,15 +6370,33 @@ dependencies = [
"log",
"prost",
"prost-build",
"protoc-bin-vendored",
"time",
"tokio",
"tokio-util",
"vnt-core",
]
[[package]]
name = "vnt-jni"
version = "2.0.2"
dependencies = [
"anyhow",
"hostname",
"ipnet",
"jni 0.21.1",
"lazy_static",
"log",
"parking_lot 0.12.5",
"serde",
"serde_json",
"tokio",
"vnt-core",
]
[[package]]
name = "vnt-web"
version = "2.0.0"
version = "2.0.2"
dependencies = [
"anyhow",
"axum",
@@ -3217,20 +6405,23 @@ dependencies = [
"log",
"mime_guess",
"parking_lot 0.12.5",
"rand 0.10.2",
"route_manager",
"rust-embed",
"serde",
"serde_json",
"time",
"tokio",
"toml",
"tower-http",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"tower",
"tower-http 0.7.0",
"vnt-core",
]
[[package]]
name = "vnt2"
version = "2.0.0"
version = "2.0.2"
dependencies = [
"anyhow",
"clap",
@@ -3241,11 +6432,31 @@ dependencies = [
"route_manager",
"serde",
"tokio",
"toml",
"toml 0.9.11+spec-1.1.0",
"vnt-ipc",
"vnt-web",
]
[[package]]
name = "vswhom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
dependencies = [
"libc",
"vswhom-sys",
]
[[package]]
name = "vswhom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "waker-fn"
version = "1.2.0"
@@ -3262,6 +6473,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3290,6 +6510,20 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
@@ -3309,7 +6543,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
"wasm-bindgen-shared",
]
@@ -3322,6 +6556,29 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
@@ -3332,6 +6589,107 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
dependencies = [
"bitflags 1.3.2",
"cairo-rs",
"gdk",
"gdk-sys",
"gio",
"gio-sys",
"glib",
"glib-sys",
"gobject-sys",
"gtk",
"gtk-sys",
"javascriptcore-rs",
"libc",
"once_cell",
"soup3",
"webkit2gtk-sys",
]
[[package]]
name = "webkit2gtk-sys"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gtk-sys",
"javascriptcore-rs-sys",
"libc",
"pkg-config",
"soup3-sys",
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
]
[[package]]
name = "webview2-com-macros"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "webview2-com-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "widestring"
version = "1.2.1"
@@ -3369,6 +6727,21 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "window-vibrancy"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
dependencies = [
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"windows-sys 0.59.0",
"windows-version",
]
[[package]]
name = "windows"
version = "0.48.0"
@@ -3445,7 +6818,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -3456,7 +6829,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -3528,6 +6901,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -3564,6 +6946,21 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -3621,6 +7018,21 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -3639,6 +7051,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -3657,6 +7075,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -3687,6 +7111,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -3705,6 +7135,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -3723,6 +7159,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -3741,6 +7183,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -3759,6 +7207,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.14"
@@ -3768,6 +7225,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -3784,6 +7250,77 @@ version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "wry"
version = "0.55.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
dependencies = [
"base64 0.22.1",
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dom_query",
"dpi",
"dunce",
"gdkx11",
"gtk",
"http",
"javascriptcore-rs",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-ui-kit",
"objc2-web-kit",
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
"url",
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
]
[[package]]
name = "x11"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "x11-dl"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
dependencies = [
"libc",
"once_cell",
"pkg-config",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
@@ -3802,6 +7339,16 @@ dependencies = [
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yasna"
version = "0.5.2"
@@ -3811,6 +7358,99 @@ dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"synstructure",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 3.0.3",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zerocopy"
version = "0.8.39"
@@ -3828,7 +7468,28 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"synstructure",
]
[[package]]
@@ -3837,8 +7498,94 @@ version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 3.0.3",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.3",
"winnow 1.0.4",
]
+30 -12
View File
@@ -1,26 +1,26 @@
[package]
name = "vnt2"
version = "2.0.0"
version = "2.0.2"
edition = "2024"
[dependencies]
vnt-ipc = { path = "vnt-ipc", optional = true }
vnt-web = { path = "vnt-web", optional = true }
clap = { version = "4.5", features = ["derive"] }
clap = { version = "4.5", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
tokio = { workspace = true, features = ["full"] }
log = "0.4"
log.workspace = true
log4rs = "1.4"
serde = { version = "1.0.228", features = ["derive"] }
anyhow = "1.0.100"
toml = "0.9.8"
hostname = "0.4.2"
ipnet = { version = "2.11", features = ["serde"] }
serde.workspace = true
anyhow.workspace = true
toml.workspace = true
hostname.workspace = true
ipnet.workspace = true
route_manager = "0.2.11"
route_manager.workspace = true
[features]
default = []
@@ -43,7 +43,26 @@ path = "src/main_web.rs"
required-features = ["vnt-web"]
[workspace]
members = ["vnt-web", "vnt-ipc", "vnt-core"]
members = ["vnt-web", "vnt-ipc", "vnt-core", "vnt-jni", "vnt-desktop/src-tauri"]
[workspace.dependencies]
vnt-core = { path = "vnt-core" }
tokio = "1"
tokio-util = "0.7"
futures = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1"
log = "0.4"
toml = "0.9"
time = "0.3"
hostname = "0.4"
ipnet = { version = "2.11", features = ["serde"] }
route_manager = "0.2"
parking_lot = "0.12"
prost = "0.14"
[profile.release]
opt-level = 'z'
@@ -51,7 +70,6 @@ debug = 0
debug-assertions = false
strip = true
lto = true
panic = 'abort'
incremental = false
codegen-units = 1
rpath = false
+36 -2
View File
@@ -8,6 +8,7 @@
1. vnt2_cli 是一个纯命令行组网工具,可以从命令行参数或配置文件快速启动组网
2. vnt2_ctrl 和vnt2_cli搭配使用,vnt2_cli后台运行时,可以用vnt2_ctrl来获取组网状态
3. vnt2_web 是一个集成web服务的组网工具,带web页面,可以在页面上操作组网
4. `vnt-desktop` 是基于 Tauri 2 的 PC 客户端,内置组网服务、桌面工作台和系统托盘
## 使用vnt2_cli组网
@@ -30,9 +31,32 @@
# 启动程序
./vnt2_web
```
2. 浏览器打开 http://127.0.0.1:19099
2. 从启动日志复制带 `?token=...` 的 Web 访问地址;也可以通过 `--token` 或 `VNT_WEB_TOKEN` 指定固定令牌
3. 在页面上添加组网配置,再启动组网
## 前端构建
web 前端源码位于 `vnt-web/ui/`(Vite + Vue 3 + Pinia + Tailwind CSS v4),构建产物输出到 `vnt-web/static/`,由 RustEmbed 嵌入二进制。
项目使用根级 pnpm workspace 统一管理 Web 与桌面前端依赖:
```
pnpm install
pnpm build:web
```
开发调试使用 `pnpm dev:web`Vite dev server 会将 `/api` 代理到 `127.0.0.1:19099`。启动 `vnt2_web` 时应指定令牌,并在浏览器登录页输入相同令牌。
## PC 客户端
桌面客户端源码位于 `vnt-desktop/`Tauri 2 + Vue 3)。桌面工作台通过 Tauri IPC 直接调用进程内 `vnt-core`;需要浏览器访问时,可在“Web 访问”中按需启用同进程 HTTP 服务,无需单独运行 `vnt2_web`。Tauri 与 Web 端统一使用 `vnt-web/ui/src/` 下的同一套响应式前端代码。
```
pnpm install
pnpm dev:desktop
```
构建安装包使用 `pnpm build:desktop`。更多说明见 `vnt-desktop/README.md`。
# VNT2.0新特性
@@ -40,12 +64,22 @@
2. 提升流量稳定性,支持使用quic代理流量,支持FEC冗余传输
3. 简化操作,去除了大量vnt1.0的重复和无用的配置参数
4. vnt-link、vnt合二为一
5. 支持有tun模式、无tun模式端口映射
5. 支持无网卡、TUN(三层)和 TAP(二层)模式端口映射;三种模式的 IPv4 流量可互通
6. 全功能的情况下,减少程序体积
7. 性能提升,支持linux-offload
8. 更规范的api接入,可以轻松自定义客户端
9. 支持同时连接多个服务端,可以容灾和负载均衡
## 虚拟网卡模式
配置文件使用 `device_mode = "no|tun|tap"`,默认值为 `tun`;命令行可用 `--device-mode` 覆盖。旧的 `no_tun` 配置已移除,程序会提示迁移而不会静默按 TUN 启动。
- `no`:不创建虚拟网卡,只提供流量出口和端口映射。
- `tun`:创建三层网卡,网卡收发 IPv4 包。
- `tap`:创建二层网卡,完整透传 Ethernet 帧,并与 TUN/NO 节点转换 IPv4、兼容 ARP。
Linux 和 macOS 使用系统提供的 TUN/TAP 能力。Windows 的 TUN 模式使用随程序提供的 `wintun.dll`;TAP 模式需要管理员权限并预先安装 `tap-windows`(硬件 ID `tap0901`)。Android VpnService 仅支持 TUN。
# 说明
vnt2.0整体重构了一遍,和1.0不兼容,同时也可能引入新的bug,欢迎反馈
+13
View File
@@ -0,0 +1,13 @@
{
"name": "vnt-workspace",
"private": true,
"packageManager": "[email protected]",
"scripts": {
"dev:web": "pnpm --filter vnt-web-ui dev",
"build:web": "pnpm --filter vnt-web-ui build",
"dev:desktop-ui": "pnpm --filter vnt-desktop dev",
"dev:desktop": "pnpm --filter vnt-desktop tauri dev",
"build:desktop-ui": "pnpm --filter vnt-desktop build",
"build:desktop": "pnpm --filter vnt-desktop tauri build"
}
}
+1733
View File
@@ -0,0 +1,1733 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
catalogs:
default:
'@tailwindcss/vite':
specifier: ^4.1.12
version: 4.3.3
'@vitejs/plugin-vue':
specifier: ^6.0.1
version: 6.0.8
pinia:
specifier: ^3.0.3
version: 3.0.4
tailwindcss:
specifier: ^4.1.12
version: 4.3.3
vite:
specifier: ^7.1.3
version: 7.3.6
vue:
specifier: ^3.5.18
version: 3.5.41
vue-router:
specifier: ^4.5.1
version: 4.6.4
importers:
.: {}
vnt-desktop:
dependencies:
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.11.1
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
pinia:
specifier: 'catalog:'
version: 3.0.4([email protected])
vue:
specifier: 'catalog:'
version: 3.5.41
vue-router:
specifier: 'catalog:'
version: 4.6.4([email protected])
devDependencies:
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.3([email protected]([email protected])([email protected]))
'@tauri-apps/cli':
specifier: ^2.8.4
version: 2.11.4
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.8([email protected]([email protected])([email protected]))([email protected])
tailwindcss:
specifier: 'catalog:'
version: 4.3.3
vite:
specifier: 'catalog:'
version: 7.3.6([email protected])([email protected])
vnt-web/ui:
dependencies:
pinia:
specifier: 'catalog:'
version: 3.0.4([email protected])
qrcode:
specifier: 1.5.4
version: 1.5.4
vue:
specifier: 'catalog:'
version: 3.5.41
vue-router:
specifier: 'catalog:'
version: 4.6.4([email protected])
devDependencies:
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.3([email protected]([email protected])([email protected]))
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.8([email protected]([email protected])([email protected]))([email protected])
tailwindcss:
specifier: 'catalog:'
version: 4.3.3
vite:
specifier: 'catalog:'
version: 7.3.6([email protected])([email protected])
packages:
'@babel/[email protected]':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/[email protected]':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@esbuild/[email protected]':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/[email protected]':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/[email protected]':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/[email protected]':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/[email protected]':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@napi-rs/[email protected]':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rollup/[email protected]':
resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==}
cpu: [arm]
os: [android]
'@rollup/[email protected]':
resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==}
cpu: [arm64]
os: [android]
'@rollup/[email protected]':
resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==}
cpu: [arm64]
os: [darwin]
'@rollup/[email protected]':
resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==}
cpu: [x64]
os: [darwin]
'@rollup/[email protected]':
resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==}
cpu: [arm64]
os: [freebsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==}
cpu: [x64]
os: [freebsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
cpu: [x64]
os: [openbsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==}
cpu: [arm64]
os: [openharmony]
'@rollup/[email protected]':
resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==}
cpu: [arm64]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==}
cpu: [ia32]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==}
cpu: [x64]
os: [win32]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
engines: {node: '>= 20'}
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==}
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@vitejs/[email protected]':
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@vue/[email protected]':
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
'@vue/[email protected]':
resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==}
'@vue/[email protected]':
resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==}
'@vue/[email protected]':
resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==}
'@vue/[email protected]':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/[email protected]':
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
'@vue/[email protected]':
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
'@vue/[email protected]':
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
'@vue/[email protected]':
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
'@vue/[email protected]':
resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==}
'@vue/[email protected]':
resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==}
'@vue/[email protected]':
resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==}
'@vue/[email protected]':
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
[email protected]:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
[email protected]:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
[email protected]:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
[email protected]:
resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
[email protected]:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
[email protected]:
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
engines: {node: '>=10.13.0'}
[email protected]:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
[email protected]:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
[email protected]:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
[email protected]:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
[email protected]:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
[email protected]:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
[email protected]:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
[email protected]:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
[email protected]:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
[email protected]:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
[email protected]:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
[email protected]:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
[email protected]:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
[email protected]:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
[email protected]:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
[email protected]:
resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
[email protected]:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
[email protected]:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
[email protected]:
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
[email protected]:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
[email protected]:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
snapshots:
'@babel/[email protected]': {}
'@babel/[email protected]': {}
'@babel/[email protected]':
dependencies:
'@babel/types': 7.29.8
'@babel/[email protected]':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@napi-rs/[email protected]':
optional: true
'@rolldown/[email protected]': {}
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@tailwindcss/[email protected]':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.24.5
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.3.3
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optional: true
'@tailwindcss/[email protected]':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.3.3
'@tailwindcss/oxide-darwin-arm64': 4.3.3
'@tailwindcss/oxide-darwin-x64': 4.3.3
'@tailwindcss/oxide-freebsd-x64': 4.3.3
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/[email protected]([email protected]([email protected])([email protected]))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 7.3.6([email protected])([email protected])
'@tauri-apps/[email protected]': {}
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optional: true
'@tauri-apps/[email protected]':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.11.4
'@tauri-apps/cli-darwin-x64': 2.11.4
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-musl': 2.11.4
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
'@tauri-apps/[email protected]':
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/[email protected]':
dependencies:
'@tauri-apps/api': 2.11.1
'@types/[email protected]': {}
'@vitejs/[email protected]([email protected]([email protected])([email protected]))([email protected])':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 7.3.6([email protected])([email protected])
vue: 3.5.41
'@vue/[email protected]':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.41
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/[email protected]':
dependencies:
'@vue/compiler-core': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]':
dependencies:
'@babel/parser': 7.29.8
'@vue/compiler-core': 3.5.41
'@vue/compiler-dom': 3.5.41
'@vue/compiler-ssr': 3.5.41
'@vue/shared': 3.5.41
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.26
source-map-js: 1.2.1
'@vue/[email protected]':
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]': {}
'@vue/[email protected]':
dependencies:
'@vue/devtools-kit': 7.7.10
'@vue/[email protected]':
dependencies:
'@vue/devtools-shared': 7.7.10
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/[email protected]':
dependencies:
rfdc: 1.4.1
'@vue/[email protected]':
dependencies:
'@vue/shared': 3.5.41
'@vue/[email protected]':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/runtime-core': 3.5.41
'@vue/shared': 3.5.41
csstype: 3.2.3
'@vue/[email protected]':
dependencies:
'@vue/compiler-ssr': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/[email protected]': {}
[email protected]: {}
[email protected]:
dependencies:
color-convert: 2.0.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
[email protected]:
dependencies:
color-name: 1.1.4
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
[email protected]: {}
[email protected]:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.5
[email protected]:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
[email protected]:
optional: true
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
[email protected]:
dependencies:
p-locate: 4.1.0
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
p-try: 2.2.0
[email protected]:
dependencies:
p-limit: 2.3.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
'@vue/devtools-api': 7.7.10
vue: 3.5.41
[email protected]: {}
[email protected]:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.9
optionalDependencies:
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
'@rollup/rollup-android-arm-eabi': 4.62.5
'@rollup/rollup-android-arm64': 4.62.5
'@rollup/rollup-darwin-arm64': 4.62.5
'@rollup/rollup-darwin-x64': 4.62.5
'@rollup/rollup-freebsd-arm64': 4.62.5
'@rollup/rollup-freebsd-x64': 4.62.5
'@rollup/rollup-linux-arm-gnueabihf': 4.62.5
'@rollup/rollup-linux-arm-musleabihf': 4.62.5
'@rollup/rollup-linux-arm64-gnu': 4.62.5
'@rollup/rollup-linux-arm64-musl': 4.62.5
'@rollup/rollup-linux-loong64-gnu': 4.62.5
'@rollup/rollup-linux-loong64-musl': 4.62.5
'@rollup/rollup-linux-ppc64-gnu': 4.62.5
'@rollup/rollup-linux-ppc64-musl': 4.62.5
'@rollup/rollup-linux-riscv64-gnu': 4.62.5
'@rollup/rollup-linux-riscv64-musl': 4.62.5
'@rollup/rollup-linux-s390x-gnu': 4.62.5
'@rollup/rollup-linux-x64-gnu': 4.62.5
'@rollup/rollup-linux-x64-musl': 4.62.5
'@rollup/rollup-openbsd-x64': 4.62.5
'@rollup/rollup-openharmony-arm64': 4.62.5
'@rollup/rollup-win32-arm64-msvc': 4.62.5
'@rollup/rollup-win32-ia32-msvc': 4.62.5
'@rollup/rollup-win32-x64-gnu': 4.62.5
'@rollup/rollup-win32-x64-msvc': 4.62.5
fsevents: 2.3.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
[email protected]:
dependencies:
ansi-regex: 5.0.1
[email protected]:
dependencies:
copy-anything: 4.1.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
picomatch: 4.0.5
[email protected]([email protected])([email protected]):
dependencies:
esbuild: 0.28.2
fdir: 6.5.0([email protected])
picomatch: 4.0.5
postcss: 8.5.26
rollup: 4.62.5
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
jiti: 2.7.0
lightningcss: 1.32.0
[email protected]([email protected]):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.41
[email protected]:
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/compiler-sfc': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/server-renderer': 3.5.41
'@vue/shared': 3.5.41
[email protected]: {}
[email protected]:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
[email protected]: {}
[email protected]:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
[email protected]:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
+12
View File
@@ -0,0 +1,12 @@
packages:
- "vnt-web/ui"
- "vnt-desktop"
catalog:
"@tailwindcss/vite": ^4.1.12
"@vitejs/plugin-vue": ^6.0.1
pinia: ^3.0.3
tailwindcss: ^4.1.12
vite: ^7.1.3
vue: ^3.5.18
vue-router: ^4.5.1
+117 -9
View File
@@ -4,7 +4,7 @@ use ipnet::Ipv4Net;
use serde::{Deserialize, Serialize};
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use vnt_core::context::config::Config;
use vnt_core::context::config::{Config, DeviceMode};
use vnt_core::nat::NetInput;
use vnt_core::tls::verifier::CertValidationMode;
use vnt_core::tunnel_core::server::transport::config::ProtocolAddress;
@@ -23,7 +23,9 @@ pub struct FileConfig {
pub input: Option<Vec<NetInput>>,
pub output: Option<Vec<Ipv4Net>>,
pub no_nat: Option<bool>,
pub no_tun: Option<bool>,
pub device_mode: Option<DeviceMode>,
#[serde(rename = "no_tun", skip_serializing)]
pub legacy_no_tun: Option<bool>,
pub mtu: Option<u16>,
pub ctrl_port: Option<u16>,
pub port_mapping: Option<Vec<String>>,
@@ -31,10 +33,12 @@ pub struct FileConfig {
pub device_id: Option<String>,
pub device_name: Option<String>,
pub tun_name: Option<String>,
pub outbound_interface: Option<String>,
pub password: Option<String>,
pub cert_mode: Option<String>,
pub udp_stun: Option<Vec<String>>,
pub tcp_stun: Option<Vec<String>>,
pub tunnel_port: Option<u16>,
}
impl FileConfig {
@@ -126,12 +130,15 @@ pub struct Args {
/// 虚拟网卡名称
#[clap(long)]
pub tun_name: Option<String>,
/// 绑定对外通信 Socket 的出口网卡名称
#[clap(long)]
pub outbound_interface: Option<String>,
/// 关闭内置子网NAT
#[clap(long)]
pub no_nat: bool,
/// 禁用tun,禁用后只能充当流量出口或者进行端口映射,无需管理员权限
/// 虚拟网卡模式:no(无网卡)、tun(三层网卡)、tap(二层网卡)
#[clap(long)]
pub no_tun: bool,
pub device_mode: Option<DeviceMode>,
/// 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
#[clap(long)]
pub port_mapping: Vec<PortMapping>,
@@ -144,6 +151,9 @@ pub struct Args {
/// 控制端口,设置0时禁用控制服务
#[clap(long)]
pub ctrl_port: Option<u16>,
/// 隧道端口,用于P2P通信
#[clap(long)]
pub tunnel_port: Option<u16>,
/// 读取配置文件
#[arg(long)]
pub conf: Option<PathBuf>,
@@ -169,6 +179,14 @@ pub fn build_config_from_args_and_file(
args: Option<Args>,
file: Option<FileConfig>,
) -> anyhow::Result<(Config, CtrlConfig)> {
if file
.as_ref()
.is_some_and(|config| config.legacy_no_tun.is_some())
{
return Err(anyhow!(
"configuration key 'no_tun' was removed; use device_mode = \"no|tun|tap\""
));
}
match (args, file) {
(Some(args), Some(file)) => build_from_args_and_file(args, file),
(Some(args), None) => build_from_args_only(args),
@@ -242,17 +260,21 @@ fn build_from_args_and_file(args: Args, file: FileConfig) -> anyhow::Result<(Con
.or_else(|| file.device_name.clone())
.unwrap_or_else(default_hostname),
tun_name: args.tun_name.or_else(|| file.tun_name.clone()),
outbound_interface: args
.outbound_interface
.or_else(|| file.outbound_interface.clone()),
password: args.password.or_else(|| file.password.clone()),
cert_mode,
input,
output,
no_nat: args.no_nat || file.no_nat.unwrap_or(false),
no_tun: args.no_tun || file.no_tun.unwrap_or(false),
device_mode: args.device_mode.or(file.device_mode).unwrap_or_default(),
mtu: args.mtu.or(file.mtu),
port_mapping,
allow_port_mapping: args.allow_mapping || file.allow_mapping.unwrap_or(false),
udp_stun,
tcp_stun,
tunnel_port: args.tunnel_port.or(file.tunnel_port),
};
let ctrl_config = CtrlConfig {
@@ -280,16 +302,18 @@ fn build_from_args_only(args: Args) -> anyhow::Result<(Config, CtrlConfig)> {
device_id,
device_name: args.device_name.unwrap_or_else(default_hostname),
tun_name: args.tun_name,
outbound_interface: args.outbound_interface,
password: args.password,
cert_mode: args
.cert_mode
.unwrap_or(CertValidationMode::InsecureSkipVerification),
output: args.output,
no_nat: args.no_nat,
no_tun: args.no_tun,
device_mode: args.device_mode.unwrap_or_default(),
mtu: args.mtu,
port_mapping: args.port_mapping,
allow_port_mapping: args.allow_mapping,
tunnel_port: args.tunnel_port,
..Default::default()
};
let ctrl_config = CtrlConfig {
@@ -299,6 +323,11 @@ fn build_from_args_only(args: Args) -> anyhow::Result<(Config, CtrlConfig)> {
}
fn build_from_file_only(file: FileConfig) -> anyhow::Result<(Config, CtrlConfig)> {
if file.legacy_no_tun.is_some() {
return Err(anyhow!(
"configuration key 'no_tun' was removed; use device_mode = \"no|tun|tap\""
));
}
let server_addr = file.to_server_addr()?;
let port_mapping = file.to_port_mapping()?;
@@ -339,16 +368,18 @@ fn build_from_file_only(file: FileConfig) -> anyhow::Result<(Config, CtrlConfig)
device_id,
device_name: file.device_name.clone().unwrap_or_else(default_hostname),
tun_name: file.tun_name.clone(),
outbound_interface: file.outbound_interface.clone(),
password: file.password.clone(),
cert_mode,
output: file.output.unwrap_or_default(),
no_nat: file.no_nat.unwrap_or(false),
no_tun: file.no_tun.unwrap_or(false),
device_mode: file.device_mode.unwrap_or_default(),
mtu: file.mtu,
port_mapping,
allow_port_mapping: file.allow_mapping.unwrap_or(false),
udp_stun,
tcp_stun,
tunnel_port: file.tunnel_port,
};
let ctrl_config = CtrlConfig {
ctrl_port: file.ctrl_port,
@@ -406,8 +437,8 @@ server = ["quic://1.2.3.4:29872"]
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
# no_nat = false
# 是否关闭TUN虚拟网卡,关闭(设为true)后只能充当流量出口或者进行端口映射,关闭后无需管理员权限
# no_tun = false
# 虚拟网卡模式:no(无网卡)、tun(三层网卡,默认)、tap(二层网卡)
# device_mode = "tun"
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
@@ -424,6 +455,9 @@ server = ["quic://1.2.3.4:29872"]
# 控制服务的 tcp 端口
# ctrl_port = 11233
# 隧道端口,用于P2P通信 (默认为0,自动分配)
# tunnel_port = 0
# MTU 设置
# mtu = 1400
@@ -438,6 +472,9 @@ server = ["quic://1.2.3.4:29872"]
# 虚拟网卡名称
# tun_name = "vnt-tun"
# 绑定对外通信 Socket 的出口网卡名称(用于服务端通信、P2P 打洞及转发流量)
# outbound_interface = "Ethernet"
# --- 安全配置 ---
# 加密密码 (可选)
@@ -466,3 +503,74 @@ server = ["quic://1.2.3.4:29872"]
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 纯参数模式下 --tunnel-port 不能被静默丢弃
#[test]
fn test_args_only_keeps_tunnel_port() {
let args = Args::try_parse_from([
"vnt",
"-s",
"quic://127.0.0.1:29872",
"-n",
"test-net",
"--tunnel-port",
"12345",
"--outbound-interface",
"Ethernet",
])
.unwrap();
let (config, _) = build_from_args_only(args).unwrap();
assert_eq!(config.tunnel_port, Some(12345));
assert_eq!(config.outbound_interface.as_deref(), Some("Ethernet"));
}
#[test]
fn test_device_mode_cli_and_legacy_rejection() {
let args = Args::try_parse_from([
"vnt",
"-s",
"quic://127.0.0.1:29872",
"-n",
"test-net",
"--device-mode",
"tap",
])
.unwrap();
let (config, _) = build_from_args_only(args).unwrap();
assert_eq!(config.device_mode, DeviceMode::Tap);
let legacy: FileConfig = toml::from_str("no_tun = true").unwrap();
let error = match build_config_from_args_and_file(None, Some(legacy)) {
Err(error) => error,
Ok(_) => panic!("legacy no_tun must be rejected"),
};
assert!(error.to_string().contains("device_mode"));
}
#[test]
fn test_device_mode_cli_overrides_file_and_file_defaults() {
let file: FileConfig = toml::from_str("device_mode = \"no\"").unwrap();
let args = Args::try_parse_from(["vnt", "-s", "quic://127.0.0.1:29872", "-n", "test-net"])
.unwrap();
let (config, _) = build_config_from_args_and_file(Some(args), Some(file)).unwrap();
assert_eq!(config.device_mode, DeviceMode::No);
let file: FileConfig = toml::from_str("device_mode = \"no\"").unwrap();
let args = Args::try_parse_from([
"vnt",
"-s",
"quic://127.0.0.1:29872",
"-n",
"test-net",
"--device-mode",
"tap",
])
.unwrap();
let (config, _) = build_config_from_args_and_file(Some(args), Some(file)).unwrap();
assert_eq!(config.device_mode, DeviceMode::Tap);
}
}
+54 -4
View File
@@ -25,9 +25,59 @@ fn extract_wintun_impl() -> io::Result<()> {
.and_then(|p| p.parent().map(|d| d.join("wintun.dll")))
.unwrap_or_else(|| Path::new("wintun.dll").to_path_buf());
if !path.exists() {
let mut file = fs::File::create(&path)?;
file.write_all(WINTUN_DLL)?;
}
ensure_dll(&path)?;
Ok(())
}
/// 确保 path 处的 dll 与内嵌版本一致,不一致(不存在/损坏/旧版)时重写。
/// 返回是否发生了写入。只按存在性判断会让损坏或旧版 dll 永久残留。
fn ensure_dll(path: &Path) -> io::Result<bool> {
let up_to_date = fs::read(path)
.map(|content| content.as_slice() == WINTUN_DLL)
.unwrap_or(false);
if up_to_date {
return Ok(false);
}
let mut file = fs::File::create(path)?;
file.write_all(WINTUN_DLL)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dll_path(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"vnt_wintun_test_{}_{}.dll",
std::process::id(),
tag
))
}
#[test]
fn test_ensure_dll_writes_when_missing() {
let path = temp_dll_path("missing");
let _ = fs::remove_file(&path);
assert!(ensure_dll(&path).unwrap());
assert_eq!(fs::read(&path).unwrap(), WINTUN_DLL);
let _ = fs::remove_file(&path);
}
#[test]
fn test_ensure_dll_rewrites_corrupt() {
let path = temp_dll_path("corrupt");
fs::write(&path, b"corrupt").unwrap();
assert!(ensure_dll(&path).unwrap());
assert_eq!(fs::read(&path).unwrap(), WINTUN_DLL);
let _ = fs::remove_file(&path);
}
#[test]
fn test_ensure_dll_skips_when_up_to_date() {
let path = temp_dll_path("uptodate");
fs::write(&path, WINTUN_DLL).unwrap();
assert!(!ensure_dll(&path).unwrap());
let _ = fs::remove_file(&path);
}
}
+38 -16
View File
@@ -1,11 +1,13 @@
use anyhow::Context;
use args_config::{build_config_from_args_and_file, Args, FileConfig};
use anyhow::{Context, bail};
use args_config::{Args, FileConfig, build_config_from_args_and_file};
use route_manager::Route;
use std::path::Path;
use vnt_ipc as vnt_core;
use vnt_core::core::NetworkManager;
use vnt_core::utils::task_control::TaskGroupManager;
use vnt_ipc::core::RegisterResponse;
pub mod args_config;
#[cfg(windows)]
@@ -83,20 +85,45 @@ async fn main0() -> anyhow::Result<()> {
let mut network_manager = NetworkManager::create_network(Box::new(config), task_group)
.await
.context("create network")?;
let reg_msg = network_manager.register().await.context("register")?;
if !network_manager.is_no_tun() {
log::info!("启动网络:{}/{}", reg_msg.ip, reg_msg.prefix_len);
network_manager.start_tun().await.context("start tun")?;
let reg_msg = loop {
let reg_msg = match network_manager.register().await {
Ok(rs) => rs,
Err(e) => {
log::error!("Register failed: {:?}", e);
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
continue;
}
};
match reg_msg {
RegisterResponse::Success(reg_msg) => {
break reg_msg;
}
RegisterResponse::Failed(e) => {
log::error!("Register failed: {:?}", e);
bail!("注册失败:{}", e.message)
}
}
};
if network_manager.device_mode().has_device() {
log::info!(
"启动网络:{}/{} ({})",
reg_msg.ip,
reg_msg.prefix_len,
network_manager.device_mode()
);
network_manager
.set_network_ip(reg_msg.ip, reg_msg.prefix_len)
.start_device()
.await
.context("start device")?;
network_manager
.set_device_network_ip(reg_msg.ip, reg_msg.prefix_len)
.await
.context("set network ip")?;
if !sub_input.is_empty() {
let if_index = network_manager
.tun_if_index()
.device_if_index()
.await
.context("tun_if_index")?;
.context("device_if_index")?;
let mut route_manager = route_manager::RouteManager::new()?;
for x in sub_input {
let route = Route::new(x.net.network().into(), x.net.prefix_len())
@@ -124,16 +151,11 @@ async fn main0() -> anyhow::Result<()> {
}
});
}
loop {
tokio::select! {
_ = network_manager.wait_all_stopped() => {
break;
}
_ = network_manager.wait_all_stopped() => {}
_ = tokio::signal::ctrl_c() => {
log::info!("Ctrl+c received!");
break;
}
}
}
drop(task_group_guard);
+17 -1
View File
@@ -15,6 +15,9 @@ struct Args {
/// 加载vnt配置路径,配置内容参考web端的配置格式
#[clap(long)]
conf: Option<PathBuf>,
/// Web API 访问令牌;未指定时会生成随机令牌并输出到日志
#[clap(long, env = "VNT_WEB_TOKEN")]
token: Option<String>,
}
#[tokio::main]
@@ -31,6 +34,19 @@ async fn main0() -> anyhow::Result<()> {
#[cfg(windows)]
extract_wintun_dll::extract_wintun();
let addr = args.addr.unwrap_or("127.0.0.1:19099".parse()?);
vnt_web::run_http_server(addr, args.conf).await?;
let token = args.token.unwrap_or_else(vnt_web::generate_access_token);
let browser_host = if addr.ip().is_unspecified() {
"127.0.0.1".to_string()
} else {
addr.ip().to_string()
};
log::info!("Web access token: {}", token);
log::info!(
"Web access URL: http://{}:{}/?token={}",
browser_host,
addr.port(),
token
);
vnt_web::run_http_server(addr, args.conf, token).await?;
Ok(())
}
+25 -19
View File
@@ -1,51 +1,49 @@
[package]
name = "vnt-core"
version = "2.0.0"
version = "2.0.2"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures = "0.3"
tokio = { workspace = true, features = ["full"] }
tokio-util = { workspace = true, features = ["codec"] }
futures.workspace = true
tun-rs = { version = "2", features = ["async", "async_framed"] }
rust-p2p-core = { version="0.4" }
tcp_ip = { git = "https://github.com/rustp2p/tcp_ip" }
tcp_ip = "0.2"
anyhow = "1"
parking_lot = "0.12"
anyhow.workspace = true
parking_lot.workspace = true
quinn = { version = "0.11", default-features = false, features = ["rustls", "runtime-tokio"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
hex = "0.4"
sha2 = "0.10"
sha2 = "0.11"
rustls-native-certs = "0.8.2"
log = "0.4"
log.workspace = true
bytes = "1.11.0"
rand = "0.9"
time = { version = "0.3", features = ["macros", "formatting", "local-offset"] }
rand = "0.10"
time = { workspace = true, features = ["macros", "formatting", "local-offset"] }
pnet_packet = "0.35"
ring = "0.17.14"
prost = "0.14"
tokio-tungstenite = "0.28.0"
tungstenite = "0.28.0"
prost.workspace = true
tokio-tungstenite = "0.30"
tungstenite = "0.30"
uuid = { version = "1.18.1", features = ["v4"] }
ipnet = { version = "2.11", features = ["serde"] }
ipnet.workspace = true
getifaddrs = "0.6.0"
dns-parser = "0.8"
lz4_flex = "0.12.0"
lz4_flex = "0.14"
reed-solomon-erasure = "6.0"
serde = { version = "1.0.228", features = ["derive"] }
serde.workspace = true
rcgen = "0.14.6"
machine-uid = "0.5.4"
zerocopy = { version = "0.8.31", features = ["derive"] }
socket2 = { version = "0.6.1", features = ["all"] }
@@ -53,6 +51,14 @@ socket2 = { version = "0.6.1", features = ["all"] }
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3.9", features = ["winreg"] }
widestring = "1.2"
windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(not(target_os = "android"))'.dependencies]
machine-uid = "0.6"
[build-dependencies]
prost-build = "0.14"
protoc-bin-vendored = "3"
+13
View File
@@ -1,6 +1,19 @@
fn main() {
let mut config = prost_build::Config::new();
match protoc_bin_vendored::protoc_bin_path() {
Ok(protoc_path) => {
config.protoc_executable(protoc_path);
}
Err(error) => {
println!(
"cargo:warning=vendored protoc unavailable ({error:?}); falling back to protoc from PATH"
);
}
}
config.protoc_arg("--experimental_allow_proto3_optional");
config
.compile_protos(
&[
+14 -2
View File
@@ -67,14 +67,26 @@ impl VntApi {
pub fn peer_nat_info(&self, ip: &Ipv4Addr) -> Option<NatInfo> {
self.app_state.get_peer_info(ip).and_then(|v| v.nat_info)
}
/// 获取指定 IP 的聚合丢包信息(所有路由合并)
pub fn packet_loss_info(&self, ip: &Ipv4Addr) -> Option<PacketLossInfo> {
self.app_state.packet_loss_stats.get_loss_info(ip)
self.app_state
.packet_loss_stats
.get_aggregated_loss_info(ip)
}
/// 获取指定 IP 的所有路由的丢包信息
pub fn packet_loss_info_by_routes(&self, ip: &Ipv4Addr) -> Vec<PacketLossInfo> {
self.app_state.packet_loss_stats.get_loss_info_by_ip(ip)
}
pub fn all_packet_loss_info(&self) -> Vec<PacketLossInfo> {
self.app_state.packet_loss_stats.get_all_loss_info()
}
pub fn reset_packet_loss(&self, ip: &Ipv4Addr) {
self.app_state.packet_loss_stats.reset(ip)
// 重置该 IP 的所有路由统计
for info in self.app_state.packet_loss_stats.get_loss_info_by_ip(ip) {
if let Some(route_key) = info.route_key {
self.app_state.packet_loss_stats.reset(ip, &route_key);
}
}
}
pub fn reset_all_packet_loss(&self) {
self.app_state.packet_loss_stats.reset_all()
+7 -2
View File
@@ -89,11 +89,13 @@ mod tests {
// --- 构造原始包 ---
let payload = vec![1u8; 200];
let original = make_packet(&payload);
let mut original = make_packet(&payload);
original.set_ethernet_flag(true);
// --- 压缩 ---
let compressed = lz.compress(original, 0).unwrap();
assert!(compressed.is_compressed());
assert!(compressed.is_ethernet());
// 压缩后的 payload 应变小
assert!(
@@ -106,11 +108,14 @@ mod tests {
// 标志应清除
assert!(!decompressed.is_compressed());
assert!(decompressed.is_ethernet());
// HEAD 不变
let mut expected_head = [0u8; HEAD_LENGTH];
expected_head[2] = 0x10;
assert_eq!(
&decompressed.buffer()[..HEAD_LENGTH],
&[0u8; HEAD_LENGTH][..],
&expected_head,
"HEAD 必须保持不变"
);
+73 -2
View File
@@ -6,7 +6,9 @@ use crate::tunnel_core::server::transport::config::{ConnectRegConfig, ProtocolAd
use anyhow::bail;
use ipnet::Ipv4Net;
use std::collections::HashSet;
use std::fmt::{Display, Formatter};
use std::net::Ipv4Addr;
use std::str::FromStr;
pub const MAX_NETWORK_CODE_LEN: usize = 32;
pub const MAX_DEVICE_ID_LEN: usize = 64;
@@ -14,6 +16,44 @@ pub const MAX_NAME_LEN: usize = 128;
pub const MAX_VERSION_LEN: usize = 32;
pub const MAX_MTU: u16 = 1500;
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DeviceMode {
No,
#[default]
Tun,
Tap,
}
impl DeviceMode {
pub fn has_device(self) -> bool {
!matches!(self, Self::No)
}
}
impl Display for DeviceMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::No => "no",
Self::Tun => "tun",
Self::Tap => "tap",
})
}
}
impl FromStr for DeviceMode {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"no" => Ok(Self::No),
"tun" => Ok(Self::Tun),
"tap" => Ok(Self::Tap),
_ => bail!("invalid device_mode '{value}', expected one of: no, tun, tap"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Config {
pub server_addr: Vec<ProtocolAddress>,
@@ -22,6 +62,8 @@ pub struct Config {
pub device_id: String,
pub device_name: String,
pub tun_name: Option<String>,
/// 绑定 VNT 对外通信 Socket 的物理网卡名称。
pub outbound_interface: Option<String>,
pub ip: Option<Ipv4Addr>,
pub password: Option<String>,
pub no_punch: bool,
@@ -31,15 +73,20 @@ pub struct Config {
pub input: Vec<NetInput>,
pub output: Vec<Ipv4Net>,
pub no_nat: bool,
pub no_tun: bool,
pub device_mode: DeviceMode,
pub mtu: Option<u16>,
pub port_mapping: Vec<PortMapping>,
pub allow_port_mapping: bool,
pub udp_stun: Vec<String>,
pub tcp_stun: Vec<String>,
pub tunnel_port: Option<u16>,
}
impl Config {
pub fn check(&self) -> anyhow::Result<()> {
#[cfg(any(target_os = "android", target_os = "ios", target_os = "tvos"))]
if self.device_mode == DeviceMode::Tap {
bail!("TAP mode is not supported on mobile VPN interfaces");
}
if self.server_addr.is_empty() {
bail!("服务器地址不能为空");
}
@@ -86,7 +133,11 @@ impl Config {
pub fn key_sign(&self) -> Option<String> {
self.password.as_ref().map(|p| PacketCrypto::key_sign(p))
}
pub(crate) fn to_connect_config(&self, index: usize) -> ConnectRegConfig {
pub(crate) fn to_connect_config(
&self,
index: usize,
default_interface: Option<rust_p2p_core::socket::LocalInterface>,
) -> ConnectRegConfig {
ConnectRegConfig {
server_addr: self.server_addr[index].clone(),
cert_mode: self.cert_mode.clone(),
@@ -96,6 +147,26 @@ impl Config {
ip: self.ip,
key_sign: self.key_sign(),
ip_variable: self.ip.is_none(),
default_interface,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_mode_parse_and_display() {
for (text, mode) in [
("no", DeviceMode::No),
("tun", DeviceMode::Tun),
("tap", DeviceMode::Tap),
] {
assert_eq!(text.parse::<DeviceMode>().unwrap(), mode);
assert_eq!(mode.to_string(), text);
}
assert!("bridge".parse::<DeviceMode>().is_err());
assert_eq!(DeviceMode::default(), DeviceMode::Tun);
}
}
+104 -20
View File
@@ -8,6 +8,7 @@ use crate::tunnel_core::server::transport::config::ProtocolAddress;
use ipnet::Ipv4Net;
use parking_lot::{Mutex, RwLock};
use rust_p2p_core::nat::NatInfo;
use rust_p2p_core::route::RouteKey;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::Ipv4Addr;
@@ -106,47 +107,59 @@ impl TrafficStats {
}
}
type PingStatsMap = HashMap<(Ipv4Addr, RouteKey), Arc<Mutex<PingStats>>>;
#[derive(Clone, Default)]
pub struct PacketLossStats {
inner: Arc<RwLock<HashMap<Ipv4Addr, Arc<Mutex<PingStats>>>>>,
inner: Arc<RwLock<PingStatsMap>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PacketLossInfo {
pub ip: Ipv4Addr,
#[serde(skip)]
pub route_key: Option<RouteKey>,
pub sent: u64,
pub received: u64,
pub loss_rate: f64,
}
impl PacketLossStats {
fn get_or_create(&self, ip: Ipv4Addr) -> Arc<Mutex<PingStats>> {
fn get_or_create(&self, ip: Ipv4Addr, route_key: RouteKey) -> Arc<Mutex<PingStats>> {
{
let read = self.inner.read();
if let Some(stats) = read.get(&ip) {
if let Some(stats) = read.get(&(ip, route_key)) {
return stats.clone();
}
}
let mut write = self.inner.write();
write
.entry(ip)
.entry((ip, route_key))
.or_insert_with(|| Arc::new(Mutex::new(PingStats::default())))
.clone()
}
pub fn record_sent(&self, ip: Ipv4Addr) {
let stats = self.get_or_create(ip);
pub fn record_sent(&self, ip: Ipv4Addr, route_key: RouteKey) {
let stats = self.get_or_create(ip, route_key);
stats.lock().sent += 1;
}
pub fn record_received(&self, ip: Ipv4Addr) {
let stats = self.get_or_create(ip);
stats.lock().received += 1;
pub fn record_received(&self, ip: Ipv4Addr, route_key: RouteKey) -> f64 {
let stats = self.get_or_create(ip, route_key);
let mut guard = stats.lock();
guard.received += 1;
// 计算并返回丢包率
if guard.sent > 0 {
1.0 - (guard.received as f64 / guard.sent as f64)
} else {
0.0
}
}
pub fn get_loss_info(&self, ip: &Ipv4Addr) -> Option<PacketLossInfo> {
pub fn get_loss_info(&self, ip: &Ipv4Addr, route_key: &RouteKey) -> Option<PacketLossInfo> {
let read = self.inner.read();
read.get(ip).map(|stats| {
read.get(&(*ip, *route_key)).map(|stats| {
let guard = stats.lock();
let loss_rate = if guard.sent > 0 {
1.0 - (guard.received as f64 / guard.sent as f64)
@@ -155,6 +168,7 @@ impl PacketLossStats {
};
PacketLossInfo {
ip: *ip,
route_key: Some(*route_key),
sent: guard.sent,
received: guard.received,
loss_rate,
@@ -162,10 +176,12 @@ impl PacketLossStats {
})
}
pub fn get_all_loss_info(&self) -> Vec<PacketLossInfo> {
/// 获取指定 IP 的所有路由的丢包信息
pub fn get_loss_info_by_ip(&self, ip: &Ipv4Addr) -> Vec<PacketLossInfo> {
let read = self.inner.read();
read.iter()
.map(|(ip, stats)| {
.filter(|((addr, _), _)| addr == ip)
.map(|((addr, route_key), stats)| {
let guard = stats.lock();
let loss_rate = if guard.sent > 0 {
1.0 - (guard.received as f64 / guard.sent as f64)
@@ -173,7 +189,8 @@ impl PacketLossStats {
0.0
};
PacketLossInfo {
ip: *ip,
ip: *addr,
route_key: Some(*route_key),
sent: guard.sent,
received: guard.received,
loss_rate,
@@ -182,13 +199,80 @@ impl PacketLossStats {
.collect()
}
pub fn reset(&self, ip: &Ipv4Addr) {
/// 获取指定 IP 的聚合丢包信息(所有路由合并)
pub fn get_aggregated_loss_info(&self, ip: &Ipv4Addr) -> Option<PacketLossInfo> {
let read = self.inner.read();
if let Some(stats) = read.get(ip) {
let mut total_sent = 0u64;
let mut total_received = 0u64;
let mut found = false;
for ((addr, _), stats) in read.iter() {
if addr == ip {
found = true;
let guard = stats.lock();
total_sent += guard.sent;
total_received += guard.received;
}
}
if found {
let loss_rate = if total_sent > 0 {
1.0 - (total_received as f64 / total_sent as f64)
} else {
0.0
};
Some(PacketLossInfo {
ip: *ip,
route_key: None,
sent: total_sent,
received: total_received,
loss_rate,
})
} else {
None
}
}
pub fn get_all_loss_info(&self) -> Vec<PacketLossInfo> {
let read = self.inner.read();
read.iter()
.map(|((ip, route_key), stats)| {
let guard = stats.lock();
let loss_rate = if guard.sent > 0 {
1.0 - (guard.received as f64 / guard.sent as f64)
} else {
0.0
};
PacketLossInfo {
ip: *ip,
route_key: Some(*route_key),
sent: guard.sent,
received: guard.received,
loss_rate,
}
})
.collect()
}
pub fn reset(&self, ip: &Ipv4Addr, route_key: &RouteKey) {
let read = self.inner.read();
if let Some(stats) = read.get(&(*ip, *route_key)) {
*stats.lock() = PingStats::default();
}
}
pub fn remove(&self, ip: &Ipv4Addr, route_key: &RouteKey) {
let mut write = self.inner.write();
write.remove(&(*ip, *route_key));
}
pub fn remove_batch(&self, keys: &[(Ipv4Addr, RouteKey)]) {
let mut write = self.inner.write();
for key in keys {
write.remove(key);
}
}
pub fn reset_all(&self) {
let read = self.inner.read();
for stats in read.values() {
@@ -440,8 +524,8 @@ impl ServerInfoCollection {
server_node.client_map.extend(map);
}
let mut client_simple_map = HashMap::<Ipv4Addr, ClientSimpleInfo>::new();
for (_, server_node) in guard.iter() {
for (_, x) in server_node.client_map.iter() {
for server_node in guard.values() {
for x in server_node.client_map.values() {
if let Some(v) = client_simple_map.get_mut(&x.ip) {
if x.online {
v.online = true;
@@ -471,7 +555,7 @@ impl ServerInfoCollection {
}
}
} else {
for (_, server_node) in guard.iter() {
for server_node in guard.values() {
if server_node.connected {
return true;
}
@@ -513,7 +597,7 @@ impl ServerInfoCollection {
}
pub fn get_server_rtt(&self, ip: &Ipv4Addr) -> Option<u32> {
let server_node_map_guard = self.server_node_map.read();
for (_, server_node) in server_node_map_guard.iter() {
for server_node in server_node_map_guard.values() {
if !server_node.connected {
continue;
}
+124 -65
View File
@@ -1,6 +1,6 @@
use crate::api::VntApi;
use crate::compression::PacketCompression;
use crate::context::config::Config;
use crate::context::config::{Config, DeviceMode};
use crate::context::{AppState, NetworkAddr, NetworkRoute};
use crate::crypto::PacketCrypto;
use crate::enhanced_tunnel::enhanced_ipv4_tunnel;
@@ -9,6 +9,7 @@ use crate::enhanced_tunnel::outbound::EnhancedOutbound;
use crate::fec::{FecDecoder, FecEncoder};
use crate::nat::internal_nat::{InternalNatInbound, PortMappingManager};
use crate::nat::{AllowSubnetExternalRoute, SubnetExternalRoute};
use crate::protocol::control_message::ErrorResponseMsg;
use crate::tun::enhanced_tun::EnhancedTunInbound;
use crate::tun::{DeviceConfig, DeviceIOManager, TunDataInbound, TunReceiver, tun_channel};
use crate::tunnel_core::outbound::{BasicOutbound, HybridOutbound};
@@ -20,8 +21,9 @@ use crate::tunnel_core::server::connection_manager::{
};
use crate::tunnel_core::server::rpc::ServerRPC;
use crate::utils::task_control::TaskGroup;
use anyhow::bail;
use anyhow::{Context, bail};
use ipnet::Ipv4Net;
#[cfg(not(target_os = "android"))]
use std::net::Ipv4Addr;
pub const DEFAULT_MTU: u16 = 1380;
@@ -47,6 +49,10 @@ pub struct NetworkManager {
tun_receiver: Option<TunReceiver>,
registration_context: Option<Box<RegistrationContext>>,
}
pub enum RegisterResponse {
Success(NetworkAddr),
Failed(ErrorResponseMsg),
}
impl NetworkManager {
pub async fn create_network(
@@ -55,11 +61,32 @@ impl NetworkManager {
) -> anyhow::Result<NetworkManager> {
let app_state = AppState::default();
config.check()?;
let outbound_interface_name = config
.outbound_interface
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_owned);
let resolved_interface =
crate::utils::socket::resolve_interface(outbound_interface_name.as_deref())?;
let default_interface = resolved_interface
.as_ref()
.map(|interface| interface.socket_interface.clone());
let canonical_interface_name = resolved_interface
.as_ref()
.map(|interface| interface.name.clone());
if let Some(name) = canonical_interface_name.as_deref() {
log::info!("绑定出口网卡: {name}");
}
let mtu = config.mtu.unwrap_or(DEFAULT_MTU);
let packet_crypto = PacketCrypto::new_from_str(config.password.as_deref());
let packet_compression = PacketCompression::new(config.compress);
let (server_manager_list, tunnel_to_server, server_rpc) =
create_server_tunnel(app_state.clone(), &config, packet_crypto.clone());
let (server_manager_list, tunnel_to_server, server_rpc) = create_server_tunnel(
app_state.clone(),
&config,
packet_crypto.clone(),
default_interface.clone(),
);
let device_io_manager = DeviceIOManager::new(task_group.clone());
let allow_subnet = AllowSubnetExternalRoute::new(config.output.clone());
@@ -69,6 +96,9 @@ impl NetworkManager {
app_state.clone(),
tunnel_to_server.clone(),
packet_crypto.clone(),
config.tunnel_port,
default_interface.clone(),
canonical_interface_name,
)
.await?;
@@ -107,11 +137,12 @@ impl NetworkManager {
fec_encoder,
);
let port_mapping_manager = PortMappingManager::new(
config.no_tun,
config.device_mode == DeviceMode::No,
config.allow_port_mapping,
app_state.network.clone(),
default_interface.clone(),
);
let internal_nat_inbound = if config.no_nat && !config.no_tun {
let internal_nat_inbound = if config.no_nat && config.device_mode != DeviceMode::No {
None
} else {
let nat_inbound = InternalNatInbound::create(
@@ -120,25 +151,32 @@ impl NetworkManager {
hybrid_outbound.clone(),
allow_subnet.clone(),
app_state.network.clone(),
config.no_tun,
config.device_mode == DeviceMode::No,
default_interface.clone(),
)
.await?;
Some(nat_inbound)
};
let (enhanced_tun_inbound, tun_receiver) = if config.no_tun {
(
let (enhanced_tun_inbound, tun_receiver) = match config.device_mode {
DeviceMode::No => (
EnhancedTunInbound::Nat(
internal_nat_inbound
.clone()
.expect("internal_nat_inbound must be Some when no_tun is true"),
.expect("internal_nat_inbound must be Some in no-device mode"),
),
None,
)
} else {
),
mode @ (DeviceMode::Tun | DeviceMode::Tap) => {
let (tun_inbound, tun_receiver) = tun_channel();
let tun_data_sender = TunDataInbound::new(tun_inbound, allow_subnet.clone());
(EnhancedTunInbound::Tun(tun_data_sender), Some(tun_receiver))
let tun_data_sender = TunDataInbound::new(tun_inbound, allow_subnet.clone(), mode);
let inbound = if mode == DeviceMode::Tap {
EnhancedTunInbound::Tap(tun_data_sender)
} else {
EnhancedTunInbound::Tun(tun_data_sender)
};
(inbound, Some(tun_receiver))
}
};
let (enhanced_inbound, enhanced_outbound) = enhanced_ipv4_tunnel(
@@ -150,6 +188,7 @@ impl NetworkManager {
password: config.password.clone(),
open_quic_client: config.rtx,
port_mapping: config.port_mapping.clone(),
device_mode: config.device_mode,
},
crate::enhanced_tunnel::TunnelComponents {
hybrid_outbound: hybrid_outbound.clone(),
@@ -200,35 +239,44 @@ impl NetworkManager {
}
/// Register with server(s) and start data handling tasks.
/// This method can only be called once.
/// Returns the registration response on success.
pub async fn register(&mut self) -> anyhow::Result<NetworkAddr> {
/// On connection-level failure the internal state is kept, so the call can be retried.
pub async fn register(&mut self) -> anyhow::Result<RegisterResponse> {
let Some(mut ctx) = self.registration_context.take() else {
bail!("register can only be called once");
};
match Self::register_impl(&self.app_state, &self.task_group, &mut ctx).await {
Ok(response) => Ok(response),
Err(e) => {
// 注册失败时归还上下文,允许调用方重试
self.registration_context = Some(ctx);
Err(e)
}
}
}
async fn register_impl(
app_state: &AppState,
task_group: &TaskGroup,
ctx: &mut RegistrationContext,
) -> anyhow::Result<RegisterResponse> {
let is_multi_server = ctx.server_managers.len() > 1;
let reg_response = if is_multi_server {
let response = if is_multi_server {
// Multi-server: coordinated pre-registration
log::info!(
"Multi-server mode: performing coordinated registration for {} servers",
ctx.server_managers.len()
);
let reg_response = coordinated_registration(&mut ctx.server_managers).await?;
log::info!(
"Coordinated registration completed, IP: {}, prefix_len: {}",
reg_response.ip,
reg_response.prefix_len
);
reg_response
coordinated_registration(&mut ctx.server_managers).await?
} else {
// Single-server: normal registration
log::info!("Single-server mode: performing normal registration");
let response = ctx.server_managers[0]
ctx.server_managers[0]
.connect_and_reg(crate::protocol::control_message::RegistrationMode::Normal)
.await?;
match response {
.await?
};
let reg_response = match response {
crate::protocol::control_message::ResponseMessage::Reg(reg) => {
log::info!(
"Registration completed, IP: {}, prefix_len: {}",
@@ -238,12 +286,11 @@ impl NetworkManager {
reg
}
crate::protocol::control_message::ResponseMessage::Error(e) => {
bail!("Registration failed: {}", e.message);
return Ok(RegisterResponse::Failed(e));
}
crate::protocol::control_message::ResponseMessage::ConfirmReg(_) => {
bail!("Unexpected ConfirmReg response");
}
}
};
let network_addr = NetworkAddr {
gateway: reg_response.gateway,
@@ -251,70 +298,82 @@ impl NetworkManager {
ip: reg_response.ip,
prefix_len: reg_response.prefix_len,
};
self.app_state.network.set(network_addr);
app_state.network.set(network_addr);
// 保存服务器版本信息
if !reg_response.server_version.is_empty() {
for (index, _) in ctx.server_managers.iter().enumerate() {
self.app_state.server_info_collection.set_server_version(
index as u32,
reg_response.server_version.clone(),
);
app_state
.server_info_collection
.set_server_version(index as u32, reg_response.server_version.clone());
}
}
// Start data handling tasks for all servers
for turn_manager in ctx.server_managers {
for turn_manager in ctx.server_managers.drain(..) {
let handler_config = Box::new(InboundHandlerConfig {
network_route: NetworkRoute::new(
self.app_state.network.clone(),
app_state.network.clone(),
ctx.subnet_external_route.clone(),
),
server_info: self.app_state.server_info_collection.clone(),
nat_info: self.app_state.nat_info.clone(),
peer_map: self.app_state.peer_map.clone(),
punch_backoff: self.app_state.punch_backoff.clone(),
server_info: app_state.server_info_collection.clone(),
nat_info: app_state.nat_info.clone(),
peer_map: app_state.peer_map.clone(),
punch_backoff: app_state.punch_backoff.clone(),
puncher: ctx.puncher.clone(),
packet_crypto: ctx.packet_crypto.clone(),
packet_compression: ctx.packet_compression.clone(),
enhanced_inbound: ctx.enhanced_inbound.clone(),
fec_decoder: ctx.fec_decoder.clone(),
});
turn_manager.data_handle_task_connected(&self.task_group, handler_config, network_addr);
turn_manager.data_handle_task_connected(task_group, handler_config, network_addr);
}
Ok(network_addr)
Ok(RegisterResponse::Success(network_addr))
}
pub fn is_no_tun(&self) -> bool {
self.config.no_tun
pub fn device_mode(&self) -> DeviceMode {
self.config.device_mode
}
pub async fn start_tun(&mut self) -> anyhow::Result<()> {
let Some(receiver) = self.tun_receiver.take() else {
bail!("start_tun can only be called once");
};
let Some(enhanced_outbound) = self.enhanced_outbound.take() else {
bail!("start_tun can only be called once");
};
pub async fn start_device(&mut self) -> anyhow::Result<()> {
if self.tun_receiver.is_none() || self.enhanced_outbound.is_none() {
bail!("start_device requires tun/tap mode and can only be called once");
}
let mut config = DeviceConfig::default();
config = config.set_mtu(self.config.mtu.unwrap_or(DEFAULT_MTU));
config = config
.set_device_mode(self.config.device_mode)
.set_mtu(self.config.mtu.unwrap_or(DEFAULT_MTU));
if self.config.device_mode == DeviceMode::Tap {
let net = self
.app_state
.get_network()
.context("network is not registered")?;
config = config.set_mac_addr(crate::ethernet::mac_from_ip(net.ip));
}
if let Some(tun_name) = self.config.tun_name.clone() {
config = config.set_tun_name(tun_name);
}
// 失败时 tun_receiver/enhanced_outbound 不会被消耗,可以重试
self.device_io_manager
.start_task(config, receiver, enhanced_outbound)
.start_task(config, &mut self.tun_receiver, &mut self.enhanced_outbound)
.await
}
#[cfg(unix)]
pub async fn start_tun_fd(&mut self, tun_fd: Option<i32>) -> anyhow::Result<()> {
let Some(receiver) = self.tun_receiver.take() else {
bail!("start_tun_fd can only be called once");
};
let Some(enhanced_outbound) = self.enhanced_outbound.take() else {
bail!("start_tun_fd can only be called once");
};
let mut config = DeviceConfig::default();
pub async fn start_device_fd(&mut self, tun_fd: Option<i32>) -> anyhow::Result<()> {
if self.tun_receiver.is_none() || self.enhanced_outbound.is_none() {
bail!("start_device_fd requires tun/tap mode and can only be called once");
}
let mut config = DeviceConfig::default()
.set_device_mode(self.config.device_mode)
.set_mtu(self.config.mtu.unwrap_or(DEFAULT_MTU));
if self.config.device_mode == DeviceMode::Tap {
let net = self
.app_state
.get_network()
.context("network is not registered")?;
config = config.set_mac_addr(crate::ethernet::mac_from_ip(net.ip));
}
if let Some(tun_fd) = tun_fd {
config = config.set_tun_fd(tun_fd);
}
@@ -322,11 +381,11 @@ impl NetworkManager {
config = config.set_tun_name(tun_name);
}
self.device_io_manager
.start_task(config, receiver, enhanced_outbound)
.start_task(config, &mut self.tun_receiver, &mut self.enhanced_outbound)
.await
}
#[cfg(not(target_os = "android"))]
pub async fn set_network_ip(&self, ip: Ipv4Addr, prefix_len: u8) -> anyhow::Result<()> {
pub async fn set_device_network_ip(&self, ip: Ipv4Addr, prefix_len: u8) -> anyhow::Result<()> {
self.device_io_manager.set_network(ip, prefix_len).await?;
Ok(())
}
@@ -336,8 +395,8 @@ impl NetworkManager {
self.app_state.stop_network();
}
#[cfg(not(target_os = "android"))]
pub async fn tun_if_index(&self) -> anyhow::Result<u32> {
self.device_io_manager.tun_if_index().await
pub async fn device_if_index(&self) -> anyhow::Result<u32> {
self.device_io_manager.device_if_index().await
}
pub async fn wait_all_stopped(&mut self) {
self.task_group.wait_all_stopped().await;
+174 -6
View File
@@ -1,12 +1,17 @@
use crate::protocol::ip_packet_protocol::{HEAD_LENGTH, NetPacket};
use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey};
use std::io;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
pub const TAG_LEN: usize = 16;
#[derive(Clone)]
pub struct PacketCrypto {
key: LessSafeKey,
/// 出站包序号,用于构造唯一 nonce。Clone 共享同一计数器。
/// 随机起始值可避免进程重启后(相同密钥)复用低序号段的 nonce。
seq: Arc<AtomicU32>,
}
impl PacketCrypto {
@@ -31,7 +36,10 @@ impl PacketCrypto {
pub fn new(key_bytes: [u8; 32]) -> Self {
let unbound = UnboundKey::new(&CHACHA20_POLY1305, &key_bytes).unwrap();
let key = LessSafeKey::new(unbound);
Self { key }
Self {
key,
seq: Arc::new(AtomicU32::new(rand::random())),
}
}
pub fn new_from_str(s: &str) -> Self {
let hash = ring::digest::digest(&ring::digest::SHA256, s.as_bytes());
@@ -39,7 +47,10 @@ impl PacketCrypto {
key_bytes.copy_from_slice(hash.as_ref());
Self::new(key_bytes)
}
/// 根据包头生成 12 字节 nonce
/// 根据包头生成 12 字节 nonce
/// nonce 只承担"唯一性"职责:seq(随机起始计数器)+ src + dst,
/// 三者构成每个 (src, dst) 流内不重复的 96 位值;
/// 头部其余字段的完整性认证由 AAD 负责,与 nonce 无关。
pub fn make_nonce<B: AsRef<[u8]>>(&self, pkt: &NetPacket<B>) -> io::Result<[u8; 12]> {
let buf = pkt.buffer();
@@ -49,7 +60,6 @@ impl PacketCrypto {
"buffer too small",
));
}
let msg_type = buf[0];
let seq = &buf[4..8];
let src = &buf[8..12];
let dst = &buf[12..16];
@@ -58,18 +68,35 @@ impl PacketCrypto {
nonce12[0..4].copy_from_slice(seq);
nonce12[4..8].copy_from_slice(dst);
nonce12[8..12].copy_from_slice(src);
nonce12[0] = msg_type;
Ok(nonce12)
}
/// AAD 承担"认证"职责:覆盖传输中不变、但不参与 nonce 的头部字节
/// byte0(msg_type)/byte2(flags)/byte3(reserved)。
/// msg_type 与 flagsCOMPRESSED/FEC/GATEWAY/ETHERNET)只由发送方设置、
/// 传输中不会被修改,必须纳入认证,否则中间人可翻转造成不可检测的
/// 丢包/语义篡改;ttl(byte1) 在中继转发时会递减,不能纳入 AAD。
fn make_aad<B: AsRef<[u8]>>(pkt: &NetPacket<B>) -> [u8; 3] {
let buf = pkt.buffer();
if buf.len() < HEAD_LENGTH {
return [0; 3];
}
[buf[0], buf[2], buf[3]]
}
/// 原地加密(in-place
/// payload 后需要预留16字节用于存放 tag
pub fn encrypt_in_place<B: AsRef<[u8]> + AsMut<[u8]>>(
&self,
pkt: &mut NetPacket<B>,
) -> io::Result<()> {
// 为每个出站包分配递增 seq,保证同一 (src, dst) 流内 nonce 不重复
// (seq 占满 4 字节,约 43 亿个包后才回绕)
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
pkt.set_seq(seq);
let nonce = Nonce::assume_unique_for_key(self.make_nonce(pkt)?);
let aad = Aad::from(Self::make_aad(pkt));
let payload = pkt.payload_mut();
let payload_len = payload.len() - TAG_LEN; // 实际 payload 长度(不含 tag 预留空间)
@@ -77,7 +104,7 @@ impl PacketCrypto {
// 只加密实际的 payload 部分
let tag = self
.key
.seal_in_place_separate_tag(nonce, Aad::empty(), &mut payload[..payload_len])
.seal_in_place_separate_tag(nonce, aad, &mut payload[..payload_len])
.map_err(|_| io::Error::other("encrypt failed"))?;
// 将 tag 写入 payload 后的预留空间
@@ -92,12 +119,13 @@ impl PacketCrypto {
pkt: &mut NetPacket<B>,
) -> io::Result<usize> {
let nonce = Nonce::assume_unique_for_key(self.make_nonce(pkt)?);
let aad = Aad::from(Self::make_aad(pkt));
let payload_with_tag = pkt.payload_mut();
let plaintext = self
.key
.open_in_place(nonce, Aad::empty(), payload_with_tag)
.open_in_place(nonce, aad, payload_with_tag)
.map_err(|_| io::Error::other("decrypt failed"))?;
Ok(plaintext.len())
}
@@ -106,6 +134,7 @@ impl PacketCrypto {
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::ip_packet_protocol::MsgType;
use bytes::BytesMut;
// 用于构造一个简单的 NetPacket,包含头 16 字节 + payload + 16 字节 TAG 预留
@@ -166,4 +195,143 @@ mod tests {
// 解密后与原文一致
assert_eq!(decrypted_payload, &original_payload[..]);
}
#[test]
fn test_nonce_unique_per_packet() {
let crypto = PacketCrypto::new([7u8; 32]);
let mut pkt1 = build_test_packet(20);
let mut pkt2 = build_test_packet(20);
let nonce1 = crypto.make_nonce(&pkt1).unwrap();
crypto.encrypt_in_place(&mut pkt1).expect("encrypt failed");
crypto.encrypt_in_place(&mut pkt2).expect("encrypt failed");
let nonce2 = crypto.make_nonce(&pkt1).unwrap();
let nonce3 = crypto.make_nonce(&pkt2).unwrap();
// 加密会自动分配递增 seq,两个相同头部的包 nonce 必须不同
assert_eq!(pkt1.seq() + 1, pkt2.seq());
assert_ne!(nonce1, nonce2);
assert_ne!(nonce2, nonce3);
// 密文也必须不同(相同明文、不同 nonce)
assert_ne!(pkt1.buffer(), pkt2.buffer());
// 两个包都能正常解密(头部 seq 不同,只比较 payload 区域)
crypto.decrypt_in_place(&mut pkt1).expect("decrypt failed");
crypto.decrypt_in_place(&mut pkt2).expect("decrypt failed");
assert_eq!(
&pkt1.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20],
&pkt2.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20]
);
}
#[test]
fn test_clone_shares_seq_counter() {
let crypto = PacketCrypto::new([9u8; 32]);
let cloned = crypto.clone();
let mut pkt1 = build_test_packet(8);
let mut pkt2 = build_test_packet(8);
crypto.encrypt_in_place(&mut pkt1).expect("encrypt failed");
cloned.encrypt_in_place(&mut pkt2).expect("encrypt failed");
assert_eq!(pkt1.seq() + 1, pkt2.seq());
}
/// nonce 与 AAD 完全由包自带的头部字节推导,与发送端状态无关:
/// 即使对端用自己的 seq 状态发包,本端仅凭头部即可正确解密。
#[test]
fn test_cross_version_compat() {
let key = [7u8; 32];
let crypto = PacketCrypto::new(key);
// 用相同密钥的另一个实例模拟对端
let peer = PacketCrypto::new(key);
// 模拟旧版本发包:seq 固定为 0,nonce 直接由头部计算
let mut pkt = build_test_packet(20);
let original: Vec<u8> = pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20].to_vec();
pkt.set_seq(0);
let nonce = Nonce::assume_unique_for_key(peer.make_nonce(&pkt).unwrap());
let aad = Aad::from(PacketCrypto::make_aad(&pkt));
let payload = pkt.payload_mut();
let payload_len = payload.len() - TAG_LEN;
let tag = peer
.key
.seal_in_place_separate_tag(nonce, aad, &mut payload[..payload_len])
.unwrap();
payload[payload_len..payload_len + TAG_LEN].copy_from_slice(tag.as_ref());
// 新版本解密旧版本的包
crypto.decrypt_in_place(&mut pkt).expect("decrypt failed");
assert_eq!(&pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20], &original[..]);
// 反向:新版本发(自动分配 seq),旧版本逻辑解密(nonce 只读头部)
let mut pkt2 = build_test_packet(20);
let original2: Vec<u8> = pkt2.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20].to_vec();
crypto.encrypt_in_place(&mut pkt2).expect("encrypt failed");
assert_ne!(pkt2.seq(), 0, "sanity check: new version assigns seq");
peer.decrypt_in_place(&mut pkt2).expect("decrypt failed");
assert_eq!(
&pkt2.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20],
&original2[..]
);
}
/// AAD 覆盖 flags(byte2):中间人翻转 COMPRESSED/FEC/GATEWAY 标志位
/// 必须导致解密失败,而不是被静默接受。
#[test]
fn test_tampered_flags_rejected() {
let crypto = PacketCrypto::new([7u8; 32]);
let mut pkt = build_test_packet(20);
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
// 翻转 flags 字节(模拟中间人篡改)
pkt.set_fec_flag(true);
assert!(
crypto.decrypt_in_place(&mut pkt).is_err(),
"tampered flags must fail authentication"
);
}
/// AAD 覆盖 msg_type(byte0):中间人篡改消息类型必须导致解密失败。
#[test]
fn test_tampered_msg_type_rejected() {
let crypto = PacketCrypto::new([7u8; 32]);
let mut pkt = build_test_packet(20);
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
pkt.set_msg_type(MsgType::Pong);
assert!(
crypto.decrypt_in_place(&mut pkt).is_err(),
"tampered msg_type must fail authentication"
);
}
/// ttl(byte1) 在中继转发时会递减,不属于 AAD:
/// 转发后 ttl 变化的包必须仍能正常解密。
#[test]
fn test_ttl_change_still_decrypts() {
let crypto = PacketCrypto::new([7u8; 32]);
let payload_len = 20;
let mut pkt = build_test_packet(payload_len);
pkt.set_ttl(15); // 初始 ttl
let original: Vec<u8> = pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + payload_len].to_vec();
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
// 模拟中继递减 ttl
pkt.set_ttl(14);
crypto
.decrypt_in_place(&mut pkt)
.expect("ttl change must not break decryption");
assert_eq!(
&pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + payload_len],
&original[..]
);
}
}
+3 -1
View File
@@ -18,7 +18,9 @@ impl PacketCrypto {
pub(crate) fn new_from_str(s: Option<&str>) -> Self {
Self {
crypto: s.map(chacha20_poly1305::PacketCrypto::new_from_str).map(Arc::new),
crypto: s
.map(chacha20_poly1305::PacketCrypto::new_from_str)
.map(Arc::new),
}
}
pub(crate) fn encrypt_reserve(&self) -> usize {
+52 -4
View File
@@ -1,9 +1,12 @@
use crate::context::config::DeviceMode;
use crate::context::{NetworkAddr, TrafficStats};
use crate::enhanced_tunnel::quic_over::quic_inbound::EnhancedQuicInbound;
use crate::ethernet::{ETHERTYPE_IPV4, build_arp_reply, parse_arp_ipv4, parse_frame};
use crate::nat::internal_nat::InternalNatInbound;
use crate::protocol::ip_packet_protocol::{HEAD_LENGTH, MsgType, NetPacket};
use crate::protocol::transmission::TransmissionBytes;
use crate::tun::enhanced_tun::EnhancedTunInbound;
use crate::tunnel_core::outbound::HybridOutbound;
use anyhow::{Context, bail};
use pnet_packet::ipv4::Ipv4Packet;
use std::net::Ipv4Addr;
@@ -14,6 +17,8 @@ pub(crate) struct EnhancedInbound {
quic_inbound: EnhancedQuicInbound,
internal_nat_inbound: Option<InternalNatInbound>,
traffic_stats: TrafficStats,
device_mode: DeviceMode,
hybrid_outbound: HybridOutbound,
}
impl EnhancedInbound {
@@ -22,12 +27,16 @@ impl EnhancedInbound {
quic_inbound: EnhancedQuicInbound,
internal_nat_inbound: Option<InternalNatInbound>,
traffic_stats: TrafficStats,
device_mode: DeviceMode,
hybrid_outbound: HybridOutbound,
) -> Self {
Self {
tun_data_inbound,
quic_inbound,
internal_nat_inbound,
traffic_stats,
device_mode,
hybrid_outbound,
}
}
pub async fn inbound(
@@ -37,26 +46,65 @@ impl EnhancedInbound {
src: Ipv4Addr,
packet: NetPacket<TransmissionBytes>,
) -> anyhow::Result<()> {
let ethernet = packet.is_ethernet();
let mut buf = packet.into_buffer();
self.traffic_stats.record_rx(src, buf.len() as u64);
buf.advance_head(HEAD_LENGTH)?;
if ethernet && parse_frame(buf.as_ref()).is_none() {
return Ok(());
}
if ethernet && self.device_mode != DeviceMode::Tap {
if let Some(arp) = parse_arp_ipv4(buf.as_ref())
&& arp.operation == 1
&& arp.target_ip == network_addr.ip
{
if let Some(reply) = build_arp_reply(buf.as_ref(), network_addr.ip) {
self.hybrid_outbound
.ethernet_unicast_outbound(*network_addr, src, reply)
.await?;
}
return Ok(());
}
if parse_frame(buf.as_ref()).is_none_or(|frame| frame.ethertype != ETHERTYPE_IPV4) {
return Ok(());
}
}
match msg_type {
MsgType::Turn => {
if let Some(internal_nat_inbound) = self.internal_nat_inbound.as_ref() {
let Some(ipv4) = Ipv4Packet::new(&buf) else {
let ip_data = if ethernet {
let Some(frame) = parse_frame(buf.as_ref()) else {
return Ok(());
};
if frame.ethertype != ETHERTYPE_IPV4 {
self.tun_data_inbound
.inbound(buf, network_addr, src, true)
.await?;
return Ok(());
}
&buf[frame.payload_offset..]
} else {
buf.as_ref()
};
let Some(ipv4) = Ipv4Packet::new(ip_data) else {
bail!("EnhancedInbound not ipv4")
};
let dest = ipv4.get_destination();
if dest != network_addr.ip && !network_addr.network().contains(&dest) {
internal_nat_inbound.send(&buf, network_addr).await?;
internal_nat_inbound.send(ip_data, network_addr).await?;
return Ok(());
}
}
self.tun_data_inbound.inbound(buf, network_addr).await?;
self.tun_data_inbound
.inbound(buf, network_addr, src, ethernet)
.await?;
}
MsgType::Broadcast | MsgType::ExcludeBroadcast => {
self.tun_data_inbound.inbound(buf, network_addr).await?;
self.tun_data_inbound
.inbound(buf, network_addr, src, ethernet)
.await?;
}
MsgType::Quic => {
let payload = buf.into_bytes().freeze();
+5 -1
View File
@@ -1,4 +1,5 @@
use crate::context::AppState;
use crate::context::config::DeviceMode;
use crate::enhanced_tunnel::inbound::EnhancedInbound;
use crate::enhanced_tunnel::outbound::EnhancedOutbound;
use crate::nat::SubnetExternalRoute;
@@ -18,6 +19,7 @@ pub(crate) struct TunnelConfig {
pub password: Option<String>,
pub open_quic_client: bool,
pub port_mapping: Vec<PortMapping>,
pub device_mode: DeviceMode,
}
pub(crate) struct TunnelComponents {
@@ -36,7 +38,7 @@ pub(crate) async fn enhanced_ipv4_tunnel(
) -> anyhow::Result<(EnhancedInbound, Option<EnhancedOutbound>)> {
let password = config.password.unwrap_or_else(|| "password".to_string());
let tun = match &tun_data_sender {
EnhancedTunInbound::Tun(tun) => Some(tun.clone()),
EnhancedTunInbound::Tun(tun) | EnhancedTunInbound::Tap(tun) => Some(tun.clone()),
EnhancedTunInbound::Nat(_) => None,
};
let (inbound, outbound) = quic_over::boot::quic_tunnel_start(
@@ -62,6 +64,8 @@ pub(crate) async fn enhanced_ipv4_tunnel(
inbound,
components.internal_nat_inbound,
app_state.traffic_stats.clone(),
config.device_mode,
components.hybrid_outbound.clone(),
);
let enhanced_outbound = outbound.map(|outbound| {
+72
View File
@@ -1,5 +1,9 @@
use crate::context::SharedNetworkAddr;
use crate::enhanced_tunnel::quic_over::quic_outbound::EnhancedQuicOutbound;
use crate::ethernet::{
ETHERTYPE_ARP, ETHERTYPE_IPV4, build_arp_reply, ip_from_mac, is_broadcast_or_multicast,
parse_arp_ipv4, parse_frame,
};
use crate::protocol::transmission::TransmissionBytes;
use crate::tunnel_core::outbound::HybridOutbound;
use pnet_packet::ipv4::Ipv4Packet;
@@ -30,6 +34,74 @@ impl EnhancedOutbound {
log::warn!("EnhancedOutbound error: {:?}", e);
}
}
pub async fn ethernet_outbound(&self, data: TransmissionBytes) -> Option<TransmissionBytes> {
match self.ethernet_outbound_impl(data).await {
Ok(reply) => reply,
Err(e) => {
log::warn!("EnhancedOutbound Ethernet error: {e:?}");
None
}
}
}
async fn ethernet_outbound_impl(
&self,
data: TransmissionBytes,
) -> anyhow::Result<Option<TransmissionBytes>> {
let Some(frame) = parse_frame(data.as_ref()) else {
return Ok(None);
};
let Some(net) = self.network.get() else {
return Ok(None);
};
match frame.ethertype {
ETHERTYPE_IPV4 => {
let Some(ipv4) = Ipv4Packet::new(&data[frame.payload_offset..]) else {
return Ok(None);
};
let src = ipv4.get_source();
let dest = ipv4.get_destination();
if dest == src || dest.is_unspecified() {
return Ok(None);
}
self.hybrid_outbound
.ethernet_ipv4_outbound(net, data, dest)
.await?;
}
ETHERTYPE_ARP => {
let Some(arp) = parse_arp_ipv4(data.as_ref()) else {
return Ok(None);
};
if arp.operation == 1 && arp.target_ip == net.gateway {
return Ok(build_arp_reply(data.as_ref(), net.gateway));
}
if arp.operation == 2 {
let dest = ip_from_mac(frame.destination).unwrap_or(arp.target_ip);
self.hybrid_outbound
.ethernet_unicast_outbound(net, dest, data)
.await?;
} else {
self.hybrid_outbound
.ethernet_broadcast_outbound(net, data)
.await?;
}
}
_ => {
if !is_broadcast_or_multicast(frame.destination)
&& let Some(dest) = ip_from_mac(frame.destination)
&& net.network().contains(&dest)
{
self.hybrid_outbound
.ethernet_unicast_outbound(net, dest, data)
.await?;
} else {
self.hybrid_outbound
.ethernet_broadcast_outbound(net, data)
.await?;
}
}
}
Ok(None)
}
async fn ipv4_outbound_impl(&self, data: TransmissionBytes) -> anyhow::Result<()> {
let Some(ipv4) = Ipv4Packet::new(data.as_ref()) else {
return Ok(());
@@ -16,6 +16,7 @@ use crate::tun::TunDataInbound;
use crate::tunnel_core::outbound::HybridOutbound;
use crate::utils::task_control::TaskGroup;
use anyhow::Context;
use pnet_packet::ipv4::Ipv4Packet;
use quinn::congestion::BbrConfig;
use quinn::crypto::rustls::QuicServerConfig;
use quinn::{ClientConfig, Endpoint, EndpointConfig, TransportConfig, default_runtime};
@@ -47,10 +48,7 @@ pub(crate) async fn quic_tunnel_start(
config: QuicTunnelConfig,
components: QuicTunnelComponents,
) -> anyhow::Result<(EnhancedQuicInbound, Option<EnhancedQuicOutbound>)> {
let ip_stack_config = IpStackConfig {
mtu: config.mtu,
..Default::default()
};
let ip_stack_config = IpStackConfig::builder().mtu(config.mtu).build();
let (ip_stack, ip_socket, quic_outbound) = if let Some(tun_data_sender) = tun_data_sender {
let (ip_stack, ip_stack_send, ip_stack_recv) = tcp_ip::ip_stack(ip_stack_config)?;
let ip_socket = tcp_ip::ip::IpSocket::bind_all(None, ip_stack.clone()).await?;
@@ -189,7 +187,13 @@ async fn ip_stack_recv_task(
log::error!("not network");
break;
};
match tun_data_sender.send((&buf[..len]).into(), &net).await {
let Some(ipv4) = Ipv4Packet::new(&buf[..len]) else {
continue;
};
match tun_data_sender
.send_ip((&buf[..len]).into(), &net, ipv4.get_source())
.await
{
Ok(_) => {}
Err(e) => {
log::error!("IP stack send error: {:?}", e);
@@ -19,10 +19,18 @@ pub struct QuicDataInbound {
}
impl QuicDataInbound {
pub async fn send(&self, data: Bytes, addr: Ipv4Addr) -> anyhow::Result<()> {
self.sender
.send((data, addr))
.await
.map_err(|_e| anyhow!("quic data inbound error"))
match self.sender.try_send((data, addr)) {
Ok(()) => Ok(()),
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
// 消费端处理不过来时丢包:channel 满不能阻塞整条 QUIC 接收循环,
// 否则一个慢消费者会卡住所有对端的入站流量
log::warn!("quic data inbound channel full, dropping packet from {addr}");
Ok(())
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
Err(anyhow!("quic data inbound error"))
}
}
}
}
impl Debug for QuicInnerInboundReceiver {
@@ -91,3 +99,41 @@ impl QuicInnerInboundReceiver {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// channel 满时 send 必须立即返回(丢包),不能阻塞接收循环
#[tokio::test]
async fn test_send_drops_when_channel_full() {
let (inbound, _receiver) = create_enhanced_inbound();
// 填满 channel(容量 256
for _ in 0..256 {
inbound
.send(Bytes::from_static(b"x"), Ipv4Addr::LOCALHOST)
.await
.unwrap();
}
// 再发送:旧实现会永久阻塞,修复后应立即返回 Ok(丢包)
let rs = tokio::time::timeout(
Duration::from_millis(200),
inbound.send(Bytes::from_static(b"y"), Ipv4Addr::LOCALHOST),
)
.await;
assert!(rs.is_ok(), "send blocked on full channel");
rs.unwrap().unwrap();
}
/// channel 关闭后 send 返回错误
#[tokio::test]
async fn test_send_errors_when_channel_closed() {
let (inbound, receiver) = create_enhanced_inbound();
drop(receiver);
let rs = inbound
.send(Bytes::from_static(b"x"), Ipv4Addr::LOCALHOST)
.await;
assert!(rs.is_err());
}
}
@@ -226,6 +226,26 @@ struct IpKey {
src: Ipv4Addr,
dest: Ipv4Addr,
}
/// 按流发送任务空闲超时:超时无包则关闭 QUIC 流并回收映射条目,
/// 避免每个 (protocol, src, dest) 三元组的流与任务永久残留
const DEST_SENDER_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// 仅当映射仍指向同一个 channel(即仍是本任务的条目)时才移除,
/// 避免条目被回收重建后,旧任务误删新条目
fn remove_sender_if_same(
map: &mut HashMap<IpKey, Sender<Bytes>>,
key: &IpKey,
tx: &Sender<Bytes>,
) -> bool {
if let Some(cur) = map.get(key)
&& cur.same_channel(tx)
{
map.remove(key);
return true;
}
false
}
async fn ip_listen_impl(
task_group: TaskGroup,
ip_socket: Arc<IpSocket>,
@@ -253,7 +273,14 @@ async fn ip_listen_impl(
} else {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(128);
spawn_dest_sender(task_group.clone(), key, rx, quic_tunnel_client.clone());
spawn_dest_sender(
task_group.clone(),
key,
tx.clone(),
rx,
dest_map.clone(),
quic_tunnel_client.clone(),
);
map.insert(key, tx.clone());
tx
@@ -275,7 +302,9 @@ async fn ip_listen_impl(
fn spawn_dest_sender(
task_group: TaskGroup,
key: IpKey,
tx: Sender<Bytes>,
mut rx: tokio::sync::mpsc::Receiver<Bytes>,
dest_map: Arc<Mutex<HashMap<IpKey, Sender<Bytes>>>>,
quic_tunnel_client: QuicTunnelClient,
) {
log::info!("send ip({}) packet {}->{}", key.protocol, key.src, key.dest);
@@ -294,9 +323,19 @@ fn spawn_dest_sender(
let mut framed = FramedWrite::new(send_stream, LengthDelimitedCodec::new());
while let Some(pkt) = rx.recv().await {
loop {
// 空闲超时后退出,回收流与映射条目
match tokio::time::timeout(DEST_SENDER_IDLE_TIMEOUT, rx.recv()).await {
Ok(Some(pkt)) => {
framed.send(pkt).await?;
}
Ok(None) => break,
Err(_) => {
log::debug!("key {:?} sender idle timeout, closing stream", key);
break;
}
}
}
Ok::<(), anyhow::Error>(())
}
@@ -305,5 +344,43 @@ fn spawn_dest_sender(
if let Err(e) = result {
log::error!("key {:?} sender task exit: {:?}", key, e);
}
// 回收映射条目(仅当仍是本任务的 channel)
remove_sender_if_same(&mut dest_map.lock(), &key, &tx);
});
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> IpKey {
IpKey {
protocol: pnet_packet::ip::IpNextHeaderProtocols::Udp,
src: Ipv4Addr::new(10, 0, 0, 1),
dest: Ipv4Addr::new(10, 0, 0, 2),
}
}
/// 空闲回收竞态:条目被重建后,旧任务退出不得误删新条目
#[test]
fn test_remove_sender_if_same() {
let key = test_key();
let mut map = HashMap::new();
let (old_tx, _old_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
map.insert(key, old_tx.clone());
// 条目被重建为新 channel
let (new_tx, _new_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
map.insert(key, new_tx);
// 旧任务退出:不允许删除新条目
assert!(!remove_sender_if_same(&mut map, &key, &old_tx));
assert!(map.contains_key(&key));
// 新任务退出:允许删除
let new_tx = map.get(&key).unwrap().clone();
assert!(remove_sender_if_same(&mut map, &key, &new_tx));
assert!(!map.contains_key(&key));
}
}
@@ -35,7 +35,9 @@ impl EnhancedQuicOutbound {
let segmented = more_fragments || offset > 0;
if !segmented {
let Some(tcp) = TcpPacket::new(ipv4.payload()) else {
return true;
// TCP 解析失败无法判断连接归属,返回 false 让其他
// 转发路径处理,不能返回 true 静默吞掉这个包
return false;
};
// 不是第一个包
if !(tcp.get_flags() & SYN == SYN && tcp.get_flags() & ACK != ACK) {
+231
View File
@@ -0,0 +1,231 @@
use crate::context::NetworkAddr;
use crate::protocol::ip_packet_protocol::HEAD_LENGTH;
use crate::protocol::transmission::TransmissionBytes;
use std::net::Ipv4Addr;
pub const ETHERNET_HEADER_LEN: usize = 14;
pub const ETHERTYPE_IPV4: u16 = 0x0800;
pub const ETHERTYPE_ARP: u16 = 0x0806;
const ETHERTYPE_VLAN: u16 = 0x8100;
const ETHERTYPE_QINQ: u16 = 0x88a8;
const ETHERTYPE_VLAN_9100: u16 = 0x9100;
const ARP_IPV4_LEN: usize = 28;
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct FrameInfo {
pub destination: [u8; 6],
pub source: [u8; 6],
pub ethertype: u16,
pub payload_offset: usize,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct ArpIpv4 {
pub operation: u16,
pub sender_mac: [u8; 6],
pub sender_ip: Ipv4Addr,
pub target_mac: [u8; 6],
pub target_ip: Ipv4Addr,
}
pub fn mac_from_ip(ip: Ipv4Addr) -> [u8; 6] {
let octets = ip.octets();
[0x02, 0x00, octets[0], octets[1], octets[2], octets[3]]
}
pub fn ip_from_mac(mac: [u8; 6]) -> Option<Ipv4Addr> {
(mac[0] == 0x02 && mac[1] == 0x00).then(|| Ipv4Addr::new(mac[2], mac[3], mac[4], mac[5]))
}
pub fn parse_frame(frame: &[u8]) -> Option<FrameInfo> {
if frame.len() < ETHERNET_HEADER_LEN {
return None;
}
let destination = frame[0..6].try_into().ok()?;
let source = frame[6..12].try_into().ok()?;
let mut ethertype = u16::from_be_bytes(frame[12..14].try_into().ok()?);
let mut payload_offset = ETHERNET_HEADER_LEN;
// Support stacked VLAN tags. Four tags is already beyond normal Q-in-Q usage
// and bounds work performed for an untrusted frame.
for _ in 0..4 {
if !matches!(
ethertype,
ETHERTYPE_VLAN | ETHERTYPE_QINQ | ETHERTYPE_VLAN_9100
) {
break;
}
if frame.len() < payload_offset + 4 {
return None;
}
ethertype = u16::from_be_bytes(
frame[payload_offset + 2..payload_offset + 4]
.try_into()
.ok()?,
);
payload_offset += 4;
}
(frame.len() >= payload_offset).then_some(FrameInfo {
destination,
source,
ethertype,
payload_offset,
})
}
pub fn parse_arp_ipv4(frame: &[u8]) -> Option<ArpIpv4> {
let info = parse_frame(frame)?;
if info.ethertype != ETHERTYPE_ARP || frame.len() < info.payload_offset + ARP_IPV4_LEN {
return None;
}
let arp = &frame[info.payload_offset..];
if u16::from_be_bytes(arp[0..2].try_into().ok()?) != 1
|| u16::from_be_bytes(arp[2..4].try_into().ok()?) != ETHERTYPE_IPV4
|| arp[4] != 6
|| arp[5] != 4
{
return None;
}
Some(ArpIpv4 {
operation: u16::from_be_bytes(arp[6..8].try_into().ok()?),
sender_mac: arp[8..14].try_into().ok()?,
sender_ip: Ipv4Addr::from(<[u8; 4]>::try_from(&arp[14..18]).ok()?),
target_mac: arp[18..24].try_into().ok()?,
target_ip: Ipv4Addr::from(<[u8; 4]>::try_from(&arp[24..28]).ok()?),
})
}
pub fn build_arp_reply(request: &[u8], own_ip: Ipv4Addr) -> Option<TransmissionBytes> {
let info = parse_frame(request)?;
let arp = parse_arp_ipv4(request)?;
if arp.operation != 1 || arp.target_ip != own_ip {
return None;
}
let frame_len = request.len().max(info.payload_offset + ARP_IPV4_LEN);
let mut bytes = TransmissionBytes::with_capacity(HEAD_LENGTH, HEAD_LENGTH + frame_len);
bytes.put(request).ok()?;
if bytes.len() < frame_len {
bytes.extend_end(frame_len - bytes.len());
}
let own_mac = mac_from_ip(own_ip);
bytes[0..6].copy_from_slice(&arp.sender_mac);
bytes[6..12].copy_from_slice(&own_mac);
let payload = info.payload_offset;
bytes[payload + 6..payload + 8].copy_from_slice(&2u16.to_be_bytes());
bytes[payload + 8..payload + 14].copy_from_slice(&own_mac);
bytes[payload + 14..payload + 18].copy_from_slice(&own_ip.octets());
bytes[payload + 18..payload + 24].copy_from_slice(&arp.sender_mac);
bytes[payload + 24..payload + 28].copy_from_slice(&arp.sender_ip.octets());
Some(bytes)
}
pub fn strip_ipv4(mut frame: TransmissionBytes) -> Option<TransmissionBytes> {
let info = parse_frame(frame.as_ref())?;
if info.ethertype != ETHERTYPE_IPV4 {
return None;
}
frame.advance_head(info.payload_offset).ok()?;
Some(frame)
}
pub fn wrap_ipv4(
mut packet: TransmissionBytes,
src_node: Ipv4Addr,
net: &NetworkAddr,
) -> Option<TransmissionBytes> {
let ipv4 = pnet_packet::ipv4::Ipv4Packet::new(packet.as_ref())?;
let destination_ip = ipv4.get_destination();
let destination_mac = if destination_ip.is_broadcast() || destination_ip == net.broadcast {
[0xff; 6]
} else if destination_ip.is_multicast() {
let octets = destination_ip.octets();
[0x01, 0x00, 0x5e, octets[1] & 0x7f, octets[2], octets[3]]
} else {
mac_from_ip(net.ip)
};
packet.retreat_head(ETHERNET_HEADER_LEN).ok()?;
packet[0..6].copy_from_slice(&destination_mac);
packet[6..12].copy_from_slice(&mac_from_ip(src_node));
packet[12..14].copy_from_slice(&ETHERTYPE_IPV4.to_be_bytes());
Some(packet)
}
pub fn is_broadcast_or_multicast(mac: [u8; 6]) -> bool {
mac == [0xff; 6] || mac[0] & 1 != 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_mac_round_trip() {
let ip = Ipv4Addr::new(10, 26, 1, 9);
assert_eq!(mac_from_ip(ip), [2, 0, 10, 26, 1, 9]);
assert_eq!(ip_from_mac(mac_from_ip(ip)), Some(ip));
assert_eq!(ip_from_mac([0; 6]), None);
}
#[test]
fn parses_vlan_ipv4() {
let mut frame = vec![0u8; 18 + 20];
frame[12..14].copy_from_slice(&ETHERTYPE_VLAN.to_be_bytes());
frame[16..18].copy_from_slice(&ETHERTYPE_IPV4.to_be_bytes());
let info = parse_frame(&frame).unwrap();
assert_eq!(info.ethertype, ETHERTYPE_IPV4);
assert_eq!(info.payload_offset, 18);
assert!(parse_frame(&[0u8; 13]).is_none());
let mut truncated_vlan = vec![0u8; 16];
truncated_vlan[12..14].copy_from_slice(&ETHERTYPE_VLAN.to_be_bytes());
assert!(parse_frame(&truncated_vlan).is_none());
}
#[test]
fn builds_arp_reply_for_node_ip() {
let sender_ip = Ipv4Addr::new(10, 26, 0, 8);
let target_ip = Ipv4Addr::new(10, 26, 0, 9);
let sender_mac = mac_from_ip(sender_ip);
let mut request = vec![0u8; 42];
request[0..6].copy_from_slice(&[0xff; 6]);
request[6..12].copy_from_slice(&sender_mac);
request[12..14].copy_from_slice(&ETHERTYPE_ARP.to_be_bytes());
request[14..16].copy_from_slice(&1u16.to_be_bytes());
request[16..18].copy_from_slice(&ETHERTYPE_IPV4.to_be_bytes());
request[18] = 6;
request[19] = 4;
request[20..22].copy_from_slice(&1u16.to_be_bytes());
request[22..28].copy_from_slice(&sender_mac);
request[28..32].copy_from_slice(&sender_ip.octets());
request[38..42].copy_from_slice(&target_ip.octets());
let reply = build_arp_reply(&request, target_ip).unwrap();
let arp = parse_arp_ipv4(reply.as_ref()).unwrap();
assert_eq!(arp.operation, 2);
assert_eq!(arp.sender_ip, target_ip);
assert_eq!(arp.sender_mac, mac_from_ip(target_ip));
assert_eq!(arp.target_ip, sender_ip);
assert_eq!(arp.target_mac, sender_mac);
}
#[test]
fn wraps_ipv4_for_tap_and_strips_it_again() {
let net = NetworkAddr {
gateway: Ipv4Addr::new(10, 26, 0, 1),
broadcast: Ipv4Addr::new(10, 26, 0, 255),
ip: Ipv4Addr::new(10, 26, 0, 9),
prefix_len: 24,
};
let src = Ipv4Addr::new(10, 26, 0, 8);
let mut packet = TransmissionBytes::with_capacity(HEAD_LENGTH, HEAD_LENGTH + 20);
packet.put(&[0u8; 20]).unwrap();
packet[0] = 0x45;
packet[12..16].copy_from_slice(&src.octets());
packet[16..20].copy_from_slice(&net.ip.octets());
let original = packet.as_ref().to_vec();
let frame = wrap_ipv4(packet, src, &net).unwrap();
let info = parse_frame(frame.as_ref()).unwrap();
assert_eq!(info.source, mac_from_ip(src));
assert_eq!(info.destination, mac_from_ip(net.ip));
assert_eq!(strip_ipv4(frame).unwrap().as_ref(), original);
}
}
+325 -2
View File
@@ -26,6 +26,8 @@ struct FecDecoderInner {
struct FecGroup {
data_shards: usize,
parity_shards: usize,
/// shard 统一长度(校验包到达后才能确定,等于编码端填充后的 max_len)
shard_size: usize,
received_original_count: usize,
received_shards: Vec<Option<Vec<u8>>>,
last_update: Instant,
@@ -44,6 +46,7 @@ impl Default for FecGroup {
Self {
data_shards: 0,
parity_shards: 0,
shard_size: 0,
received_original_count: 0,
received_shards: Vec::with_capacity(16),
last_update: Instant::now(),
@@ -112,22 +115,58 @@ impl FecDecoder {
);
bail!("packet_index overflow {src_ip}");
}
// 校验包索引必须落在 [data_shards, data_shards+parity_shards) 区间,
// 否则会覆盖数据区 shard,污染整个 group
if packet_index < data_shards {
log::warn!(
"parity packet_index in data region, src={src_ip},group_id={group_id}, packet_index={packet_index}, data_shards={data_shards}"
);
return Ok(None);
}
if group.data_shards != 0 && group.data_shards != data_shards {
bail!("group data_shards!=data_shards {src_ip}");
}
if group.parity_shards != 0 && group.parity_shards != parity_shards {
bail!("group parity_shards!=parity_shards {src_ip}");
}
// 尺寸未知时到达的越界数据包可能已把 received_shards 撑大,
// 该 group 已无法解码,直接放弃(等超时 GC)
if group.received_shards.len() > data_shards + parity_shards {
log::warn!(
"fec group polluted by out-of-range packet_index, src={src_ip},group_id={group_id}, received_shards={}, total={}",
group.received_shards.len(),
data_shards + parity_shards
);
bail!("fec group polluted {src_ip}");
}
group.data_shards = data_shards;
group.parity_shards = parity_shards;
// 校验包在线上即编码端填充后的 shard 统一长度
group.shard_size = payload.len();
if group.received_shards.len() < data_shards + parity_shards {
group
.received_shards
.resize(data_shards + parity_shards, None);
}
// RS 要求所有 shard 等长:把先到的数据 shard 补齐到 shard_size
// (编码端发送前把所有数据 shard 填充到 max_len
for shard in group.received_shards[..data_shards].iter_mut().flatten() {
if shard.len() < group.shard_size {
shard.resize(group.shard_size, 0);
}
}
group.received_shards[packet_index] = Some(payload);
} else {
// group 尺寸已知时,数据包索引必须落在数据区,
// 否则会把 received_shards 撑出 data_shards+parity_shards,污染整个 group
if group.data_shards != 0 && packet_index >= group.data_shards {
log::warn!(
"data packet_index overflow, src={src_ip},group_id={group_id}, packet_index={packet_index}, data_shards={}",
group.data_shards
);
return Ok(None);
}
let buffer = TransmissionBytes::zeroed(HEAD_LENGTH + payload.len());
let mut result_packet = NetPacket::new(buffer)?;
result_packet.head_mut().copy_from_slice(net_packet.head());
@@ -142,11 +181,19 @@ impl FecDecoder {
// 保存FEC数据: [type_byte, flags_byte, payload_len(u16), payload...]
let type_byte = net_packet.head()[0];
let flags_byte = net_packet.head()[2];
// 长度字段为 u16,超限必须拒绝而非静默截断(实际 payload 远小于此值,
// 这里是防御性检查)
let payload_len = u16::try_from(payload.len())
.map_err(|_| anyhow::anyhow!("fec payload too large: {}", payload.len()))?;
let mut batch_data = vec![0u8; 4 + payload.len()];
batch_data[0] = type_byte;
batch_data[1] = flags_byte;
batch_data[2..4].copy_from_slice(&(payload.len() as u16).to_be_bytes());
batch_data[2..4].copy_from_slice(&payload_len.to_be_bytes());
batch_data[4..].copy_from_slice(&payload);
// 校验包先到时 shard 尺寸已知,补齐保持所有 shard 等长
if group.shard_size != 0 && batch_data.len() < group.shard_size {
batch_data.resize(group.shard_size, 0);
}
group.received_shards[packet_index] = Some(batch_data);
group.received_original_count += 1;
}
@@ -161,7 +208,26 @@ impl FecDecoder {
return Ok(packet.map(|v| vec![v]));
}
let result = Self::try_decode(group, (src_ip, group_id), &net_packet)?;
// 解码失败(如对端构造的异常 group)只放弃该 group
// 不能向上传播错误——否则当前完好接收的包会被连带丢弃
let decode_failed;
let result = match Self::try_decode(group, (src_ip, group_id), &net_packet) {
Ok(result) => {
decode_failed = false;
result
}
Err(e) => {
log::warn!(
"fec decode failed, drop group, src={src_ip},group_id={group_id}: {e:?}"
);
decode_failed = true;
None
}
};
if decode_failed {
// 移除坏 group,后续该 group 的完好数据包按新 group 正常透传
inner.groups.remove(&(src_ip, group_id));
}
if inner.last_cleanup.elapsed() > Duration::from_secs(1) {
Self::cleanup_old_groups(&mut inner.groups);
@@ -300,3 +366,260 @@ impl FecDecoder {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fec::encoder::ParityData;
const SRC: u32 = 0x0A000001;
const DST: u32 = 0x0A000002;
/// 构造线上的 FEC 数据包(payload 为 prost 编码的 FecPacket
fn build_data_packet(
group_id: u64,
packet_index: u32,
type_byte: u8,
flags_byte: u8,
payload: &[u8],
) -> NetPacket<TransmissionBytes> {
let fec = FecPacket {
group_id,
packet_index,
payload: payload.to_vec(),
parity_data: None,
};
build_packet(fec, type_byte, flags_byte)
}
fn build_parity_packet(
group_id: u64,
packet_index: u32,
payload: Vec<u8>,
data_shards: u32,
parity_shards: u32,
) -> NetPacket<TransmissionBytes> {
let fec = FecPacket {
group_id,
packet_index,
payload,
parity_data: Some(ParityData {
data_shards,
parity_shards,
}),
};
// 校验包头部的 type/flags 不参与数据重建,固定取值
build_packet(fec, 0x81, 0)
}
fn build_packet(fec: FecPacket, type_byte: u8, flags_byte: u8) -> NetPacket<TransmissionBytes> {
let fec_payload = fec.encode_to_vec();
let buffer = TransmissionBytes::zeroed(HEAD_LENGTH + fec_payload.len());
let mut pkt = NetPacket::new(buffer).unwrap();
pkt.head_mut()[0] = type_byte;
pkt.head_mut()[2] = flags_byte;
pkt.set_src_id(SRC);
pkt.set_dest_id(DST);
pkt.set_ttl(5);
pkt.set_fec_flag(true);
pkt.set_payload(&fec_payload).unwrap();
pkt
}
/// 按编码端格式组装 shard[type_byte, flags_byte, len(u16), payload...],填充到 max_len
fn make_shard(type_byte: u8, flags_byte: u8, payload: &[u8], max_len: usize) -> Vec<u8> {
let mut shard = vec![0u8; max_len];
shard[0] = type_byte;
shard[1] = flags_byte;
shard[2..4].copy_from_slice(&(payload.len() as u16).to_be_bytes());
shard[4..4 + payload.len()].copy_from_slice(payload);
shard
}
#[test]
fn test_original_fec_packet_preserves_ethernet_flag() {
let decoder = FecDecoder::new();
let packets = decoder
.receive(build_data_packet(99, 0, 0x81, 0x10, &[1, 2, 3]))
.unwrap()
.unwrap();
assert_eq!(packets.len(), 1);
assert!(packets[0].is_ethernet());
assert!(!packets[0].is_fec());
}
/// 变长批次:丢一个数据包,靠校验包必须能恢复(修复前必报 IncorrectShardSize
#[test]
fn test_reconstruct_variable_length_batch() {
let decoder = FecDecoder::new();
let group_id = 1u64;
let payloads: [&[u8]; 3] = [&[0xAA; 10], &[0xBB; 30], &[0xCC; 20]];
let type_bytes = [0x81u8, 0x82, 0x83];
let max_len = 4 + 30; // 编码端按最长 shard 填充
// 编码端:3 数据 + 1 校验
let mut shards: Vec<Vec<u8>> = payloads
.iter()
.zip(type_bytes)
.map(|(p, t)| make_shard(t, 0, p, max_len))
.collect();
shards.push(vec![0u8; max_len]);
let rs = ReedSolomon::new(3, 1).unwrap();
{
let mut refs: Vec<&mut [u8]> = shards.iter_mut().map(|s| s.as_mut()).collect();
rs.encode(&mut refs).unwrap();
}
let parity = shards[3].clone();
// 收到 pkt0、pkt1pkt2 丢失,随后收到校验包
assert!(
decoder
.receive(build_data_packet(
group_id,
0,
type_bytes[0],
0,
payloads[0]
))
.unwrap()
.is_some()
);
assert!(
decoder
.receive(build_data_packet(
group_id,
1,
type_bytes[1],
0,
payloads[1]
))
.unwrap()
.is_some()
);
let recovered = decoder
.receive(build_parity_packet(group_id, 3, parity, 3, 1))
.unwrap()
.expect("should reconstruct the lost packet");
assert_eq!(recovered.len(), 1);
assert_eq!(recovered[0].payload(), payloads[2]);
assert_eq!(recovered[0].head()[0], type_bytes[2]);
assert_eq!(recovered[0].src_id(), SRC);
assert_eq!(recovered[0].dest_id(), DST);
assert!(!recovered[0].is_fec());
}
/// 校验包先到时,后到的数据 shard 也要补齐(等长)后能恢复
#[test]
fn test_reconstruct_when_parity_arrives_first() {
let decoder = FecDecoder::new();
let group_id = 2u64;
let payloads: [&[u8]; 2] = [&[0x11; 8], &[0x22; 24]];
let max_len = 4 + 24;
let mut shards = [
make_shard(0x81, 0, payloads[0], max_len),
make_shard(0x82, 0, payloads[1], max_len),
vec![0u8; max_len],
];
let rs = ReedSolomon::new(2, 1).unwrap();
{
let mut refs: Vec<&mut [u8]> = shards.iter_mut().map(|s| s.as_mut()).collect();
rs.encode(&mut refs).unwrap();
}
let parity = shards[2].clone();
// 校验包先到(此时丢失 pkt0 还未知),再收到 pkt1
assert!(
decoder
.receive(build_parity_packet(group_id, 2, parity, 2, 1))
.unwrap()
.is_none()
);
let recovered = decoder
.receive(build_data_packet(group_id, 1, 0x82, 0, payloads[1]))
.unwrap()
.expect("should reconstruct pkt0 after parity-first");
// 校验包先到时 pkt1 会透传,恢复的 pkt0 也应在返回列表中
let recovered_pkt0 = recovered
.iter()
.find(|p| p.payload() == payloads[0])
.expect("recovered pkt0 missing");
assert_eq!(recovered_pkt0.head()[0], 0x81);
}
/// 异常 group(校验 shard 比数据 shard 短)导致重建失败时:
/// 当前包不被连带丢弃,坏 group 被移除,后续数据包正常透传
#[test]
fn test_decode_failure_does_not_swallow_good_packets() {
let decoder = FecDecoder::new();
let group_id = 3u64;
// 先到一个较大的数据包
assert!(
decoder
.receive(build_data_packet(group_id, 0, 0x81, 0, &[0xAA; 40]))
.unwrap()
.is_some()
);
// 异常校验包:shard 只有 4 字节,比已存数据 shard 短,reconstruct 必失败
assert!(
decoder
.receive(build_parity_packet(group_id, 2, vec![0u8; 4], 2, 1))
.unwrap()
.is_none()
);
// 坏 group 已被移除,后续数据包按新 group 正常透传
let passed = decoder
.receive(build_data_packet(group_id, 1, 0x82, 0, &[0xBB; 8]))
.unwrap()
.expect("good packet must not be swallowed");
assert_eq!(passed.len(), 1);
assert_eq!(passed[0].payload(), &[0xBB; 8][..]);
}
/// 越界数据包不污染 group:尺寸已知后到达的越界 packet_index 必须被丢弃,
/// 不能把 received_shards 撑出 data_shards+parity_shards 导致整组解码失败
#[test]
fn test_out_of_range_data_index_does_not_poison_group() {
let decoder = FecDecoder::new();
let group_id = 1u64;
let shard = make_shard(0x81, 0, &[0xAA; 10], 14);
// 校验包先到:data_shards=2, parity_shards=1,索引 2
let parity = build_parity_packet(group_id, 2, shard, 2, 1);
decoder.receive(parity).unwrap();
// 越界数据包(index 5 >= data_shards 2):必须被丢弃
let evil = build_data_packet(group_id, 5, 0x81, 0, &[0xEE; 10]);
assert!(decoder.receive(evil).unwrap().is_none());
// 合法数据包 p0received_shards 未被撑大,正常触发重构,
// 返回 p0 和恢复出的 p1 共 2 个包(修复前 group 已被污染,解码失败只返回 p0)
let p0 = build_data_packet(group_id, 0, 0x81, 0, &[0xAA; 10]);
let out = decoder.receive(p0).unwrap().expect("packet 0 delivered");
assert_eq!(out.len(), 2, "should deliver p0 and recovered p1");
// group 已完成,p1 重传被忽略
let p1 = build_data_packet(group_id, 1, 0x81, 0, &[0xBB; 10]);
assert!(decoder.receive(p1).unwrap().is_none());
}
/// 落在数据区的校验包索引必须被拒绝,且不能破坏 group
#[test]
fn test_parity_index_in_data_region_rejected() {
let decoder = FecDecoder::new();
let group_id = 1u64;
let shard = make_shard(0x81, 0, &[0xAA; 10], 14);
// 索引 0 < data_shards=2 的"校验包":必须拒绝
let bad_parity = build_parity_packet(group_id, 0, shard.clone(), 2, 1);
assert!(decoder.receive(bad_parity).unwrap().is_none());
// group 仍可用:合法校验包 + 数据包正常处理
let parity = build_parity_packet(group_id, 2, shard, 2, 1);
decoder.receive(parity).unwrap();
let p0 = build_data_packet(group_id, 0, 0x81, 0, &[0xAA; 10]);
assert!(decoder.receive(p0).unwrap().is_some());
}
}
+2
View File
@@ -16,6 +16,8 @@ mod fec_proto {
}
use crate::utils::task_control::TaskGroup;
pub use fec_proto::FecPacket;
#[cfg(test)]
pub use fec_proto::ParityData;
const BATCH_SIZE: usize = 10;
const REDUNDANCY_RATE: f32 = 0.2;
+1
View File
@@ -2,6 +2,7 @@ pub(crate) mod compression;
pub mod context;
pub mod core;
pub mod crypto;
pub(crate) mod ethernet;
pub(crate) mod fec;
pub mod nat;
pub mod protocol;
+111 -12
View File
@@ -5,17 +5,27 @@ use pnet_packet::Packet;
use pnet_packet::icmp::echo_reply::{Identifier, SequenceNumber};
use pnet_packet::icmp::{IcmpPacket, IcmpTypes};
use pnet_packet::ipv4::Ipv4Packet;
use rust_p2p_core::socket::LocalInterface;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::time::{Duration, Instant};
use tcp_ip::IpStack;
use tcp_ip::icmp::IcmpSocket;
use tokio::net::UdpSocket;
/// ICMP echo 映射超时:正常 ping 应答在秒级返回,超时条目视为无应答残留
const ICMP_NAT_TIMEOUT: Duration = Duration::from_secs(60);
const ICMP_NAT_GC_INTERVAL: Duration = Duration::from_secs(60);
/// (对端地址, identifier, sequence) -> (内网客户端地址, 创建时间)
type IcmpNatMap = HashMap<(Ipv4Addr, Identifier, SequenceNumber), (Ipv4Addr, Instant)>;
pub async fn start_icmp_nat(
task_group: &TaskGroup,
ip_stack: &IpStack,
no_tun: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
let net_icmp_socket = socket2::Socket::new(
socket2::Domain::IPV4,
@@ -23,6 +33,12 @@ pub async fn start_icmp_nat(
Some(socket2::Protocol::ICMPV4),
)
.context("new Socket RAW ICMPV4 failed")?;
crate::utils::socket::bind_socket_to_interface(
&net_icmp_socket,
default_interface.as_ref(),
true,
)
.context("bind ICMP socket to outbound interface failed")?;
let addr: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0);
net_icmp_socket
.bind(&socket2::SockAddr::from(addr))
@@ -49,24 +65,58 @@ async fn task(
) -> anyhow::Result<()> {
let mut buf1 = vec![0u8; 65536];
let mut buf2 = vec![0u8; 65536];
let mut map = HashMap::new();
let mut map: IcmpNatMap = HashMap::new();
let mut gc_interval = tokio::time::interval(ICMP_NAT_GC_INTERVAL);
loop {
// 单次收发/处理失败不能拖垮整个任务:记录日志后继续,
// 短暂休眠避免持续性错误造成空转
tokio::select! {
rs = tokio_icmp_socket.recv(&mut buf1) => {
let len = rs?;
tokio_icmp_socket_recv(&buf1[..len],&inner_icmp_socket,&map,no_tun,&network).await?;
match rs {
Ok(len) => {
if let Err(e) = tokio_icmp_socket_recv(&buf1[..len],&inner_icmp_socket,&mut map,no_tun,&network).await {
log::warn!("icmp nat outbound error: {e:?}");
}
}
Err(e) => {
log::warn!("icmp nat recv error: {e:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
rs = inner_icmp_socket.recv_from_to(&mut buf2) => {
let (len,src,dst) = rs?;
inner_icmp_socket_recv(&buf2[..len],src,dst,&tokio_icmp_socket,&mut map,no_tun,&network).await?;
match rs {
Ok((len,src,dst)) => {
if let Err(e) = inner_icmp_socket_recv(&buf2[..len],src,dst,&tokio_icmp_socket,&mut map,no_tun,&network).await {
log::warn!("icmp nat inbound error: {e:?}");
}
}
Err(e) => {
log::warn!("icmp nat inner recv error: {e:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
_ = gc_interval.tick() => {
evict_expired(&mut map, Instant::now(), ICMP_NAT_TIMEOUT);
}
}
}
}
/// 清理超时未收到应答的映射条目,防止 map 无界增长
fn evict_expired(map: &mut IcmpNatMap, now: Instant, timeout: Duration) {
let before = map.len();
map.retain(|_, (_, created)| now.duration_since(*created) < timeout);
let evicted = before - map.len();
if evicted > 0 {
log::debug!("icmp nat evicted {} expired entries", evicted);
}
}
async fn tokio_icmp_socket_recv(
buf: &[u8],
inner_icmp_socket: &IcmpSocket,
map: &HashMap<(Ipv4Addr, Identifier, SequenceNumber), Ipv4Addr>,
map: &mut IcmpNatMap,
no_tun: bool,
network: &SharedNetworkAddr,
) -> anyhow::Result<()> {
@@ -88,15 +138,20 @@ async fn tokio_icmp_socket_recv(
let mut src = ipv4.get_source();
let identifier = Identifier::new(u16::from_be_bytes([payload[0], payload[1]]));
let sequence_number = SequenceNumber::new(u16::from_be_bytes([payload[2], payload[3]]));
let Some(dst) = map.get(&(src, identifier, sequence_number)) else {
// 收到应答即完成一次 echo 交换,移除映射,避免条目残留
let Some((dst, _)) = map.remove(&(src, identifier, sequence_number)) else {
return Ok(());
};
if no_tun && src == Ipv4Addr::LOCALHOST {
src = network.ip().context("not ip")?;
// 虚拟地址未就绪时丢弃该应答包,而不是让错误传播杀掉整个任务
let Some(ip) = network.ip() else {
return Ok(());
};
src = ip;
}
inner_icmp_socket
.send_from_to(ipv4.payload(), src.into(), (*dst).into())
.send_from_to(ipv4.payload(), src.into(), dst.into())
.await
.context("sending ICMPv4 failed")?;
Ok(())
@@ -106,7 +161,7 @@ async fn inner_icmp_socket_recv(
src: IpAddr,
dst: IpAddr,
tokio_icmp_socket: &UdpSocket,
map: &mut HashMap<(Ipv4Addr, Identifier, SequenceNumber), Ipv4Addr>,
map: &mut IcmpNatMap,
no_tun: bool,
network: &SharedNetworkAddr,
) -> anyhow::Result<()> {
@@ -125,15 +180,59 @@ async fn inner_icmp_socket_recv(
if payload.len() < 4 {
return Ok(());
}
if no_tun && dst == network.ip().context("not ip")? {
// 虚拟地址未就绪(None)时跳过重写,而不是让错误传播杀掉整个任务
if no_tun && Some(dst) == network.ip() {
dst = Ipv4Addr::LOCALHOST;
}
let identifier = Identifier::new(u16::from_be_bytes([payload[0], payload[1]]));
let sequence_number = SequenceNumber::new(u16::from_be_bytes([payload[2], payload[3]]));
map.insert((dst, identifier, sequence_number), src);
map.insert((dst, identifier, sequence_number), (src, Instant::now()));
tokio_icmp_socket
.send_to(buf, SocketAddr::new(dst.into(), 0))
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(
ip: &str,
id: u16,
seq: u16,
created: Instant,
) -> ((Ipv4Addr, Identifier, SequenceNumber), (Ipv4Addr, Instant)) {
(
(
ip.parse().unwrap(),
Identifier::new(id),
SequenceNumber::new(seq),
),
(Ipv4Addr::new(10, 0, 0, 1), created),
)
}
/// 超时未应答的条目必须被清理,未超时的保留,map 不会无界增长
#[test]
fn test_evict_expired() {
let now = Instant::now();
let mut map: IcmpNatMap = HashMap::new();
let (k_fresh, v_fresh) = entry("8.8.8.8", 1, 1, now);
let (k_stale, v_stale) = entry(
"1.1.1.1",
2,
2,
now - ICMP_NAT_TIMEOUT - Duration::from_secs(1),
);
map.insert(k_fresh, v_fresh);
map.insert(k_stale, v_stale);
evict_expired(&mut map, now, ICMP_NAT_TIMEOUT);
assert_eq!(map.len(), 1);
assert!(map.contains_key(&k_fresh));
assert!(!map.contains_key(&k_stale));
}
}
+66 -14
View File
@@ -8,6 +8,7 @@ use anyhow::Context;
use bytes::BytesMut;
use pnet_packet::ip::IpNextHeaderProtocol;
use pnet_packet::ipv4::Ipv4Packet;
use rust_p2p_core::socket::LocalInterface;
use std::net::{Ipv4Addr, SocketAddr};
use std::str::FromStr;
use std::sync::Arc;
@@ -24,6 +25,7 @@ pub(crate) struct InternalNatInbound {
ip_stack_send: Arc<IpStackSend>,
allow_subnet: AllowSubnetExternalRoute,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
}
impl InternalNatInbound {
pub async fn create(
@@ -33,16 +35,28 @@ impl InternalNatInbound {
allow_subnet: AllowSubnetExternalRoute,
network: SharedNetworkAddr,
no_tun: bool,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<Self> {
let ip_stack_config = IpStackConfig {
mtu,
..Default::default()
};
let ip_stack_config = IpStackConfig::builder().mtu(mtu).build();
let (ip_stack, ip_stack_send, ip_stack_recv) = tcp_ip::ip_stack(ip_stack_config)?;
#[cfg(not(target_os = "android"))]
icmp_nat::start_icmp_nat(task_group, &ip_stack, no_tun, network.clone()).await?;
tcp_nat::start_tcp_nat(task_group, &ip_stack, no_tun, network.clone()).await?;
udp_nat::start_udp_nat(task_group, &ip_stack).await?;
icmp_nat::start_icmp_nat(
task_group,
&ip_stack,
no_tun,
network.clone(),
default_interface.clone(),
)
.await?;
tcp_nat::start_tcp_nat(
task_group,
&ip_stack,
no_tun,
network.clone(),
default_interface.clone(),
)
.await?;
udp_nat::start_udp_nat(task_group, &ip_stack, default_interface.clone()).await?;
task_group.spawn(async move {
if let Err(e) = ip_stack_recv_task(ip_stack_recv, hybrid_outbound).await {
log::error!("ip stack recv task error: {e:?}");
@@ -53,10 +67,11 @@ impl InternalNatInbound {
ip_stack_send: Arc::new(ip_stack_send),
allow_subnet,
network,
default_interface,
})
}
pub async fn send(&self, data: &[u8], net: &NetworkAddr) -> anyhow::Result<()> {
if data[0] >> 4 != 4 {
if data.is_empty() || data[0] >> 4 != 4 {
return Ok(());
}
let Some(ipv4) = Ipv4Packet::new(data) else {
@@ -143,7 +158,13 @@ impl InternalNatInbound {
}
}
let dst = SocketAddr::new(dest_ip.into(), dest_port);
tcp_nat::stream_nat(recv_stream, send_stream, dst).await
tcp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await
}
}
@@ -152,14 +173,21 @@ pub(crate) struct PortMappingManager {
no_tun: bool,
allow_port_mapping: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
}
impl PortMappingManager {
pub fn new(no_tun: bool, allow_port_mapping: bool, network: SharedNetworkAddr) -> Self {
pub fn new(
no_tun: bool,
allow_port_mapping: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> Self {
Self {
no_tun,
allow_port_mapping,
network,
default_interface,
}
}
pub async fn tcp_mapping<R, W>(
@@ -184,13 +212,25 @@ impl PortMappingManager {
if dest_ip == net.ip {
let dst = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), dest_port);
return tcp_nat::stream_nat(recv_stream, send_stream, dst).await;
return tcp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await;
} else if net.network().contains(&dest_ip) {
return Ok(());
}
}
let dst = format!("{}:{}", dest, dest_port);
tcp_nat::stream_nat(recv_stream, send_stream, dst).await
tcp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await
}
pub async fn udp_mapping<R, W>(
&self,
@@ -214,12 +254,24 @@ impl PortMappingManager {
if dest_ip == net.ip {
let dst = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), dest_port);
return udp_nat::stream_nat(recv_stream, send_stream, dst).await;
return udp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await;
} else if net.network().contains(&dest_ip) {
return Ok(());
}
}
let dst = format!("{}:{}", dest, dest_port);
udp_nat::stream_nat(recv_stream, send_stream, dst).await
udp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await
}
}
+42 -17
View File
@@ -1,23 +1,26 @@
use crate::context::SharedNetworkAddr;
use crate::utils::task_control::TaskGroup;
use anyhow::Context;
use rust_p2p_core::socket::LocalInterface;
use std::fmt::Debug;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tcp_ip::IpStack;
use tcp_ip::tcp::TcpListener;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::{TcpStream, ToSocketAddrs};
use tokio::net::ToSocketAddrs;
pub async fn start_tcp_nat(
task_group: &TaskGroup,
ip_stack: &IpStack,
no_tun: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
let tcp_listener = TcpListener::bind_all(ip_stack.clone()).await?;
let group = task_group.clone();
task_group.spawn(async move {
if let Err(e) = listen_task(&group, tcp_listener, no_tun, network).await {
if let Err(e) = listen_task(&group, tcp_listener, no_tun, network, default_interface).await
{
log::error!("listen task error: {:?}", e);
}
});
@@ -29,22 +32,41 @@ async fn listen_task(
mut tcp_listener: TcpListener,
no_tun: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
loop {
let (stream, _addr) = tcp_listener.accept().await?;
let mut local_addr = stream.local_addr()?;
let peer_addr = stream.peer_addr()?;
// 单次 accept/地址查询失败不能拖垮整个监听任务:
// 记录日志后继续,短暂休眠避免持续性错误造成空转
let (stream, _addr) = match tcp_listener.accept().await {
Ok(v) => v,
Err(e) => {
log::warn!("tcp nat accept error: {e:?}");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
};
let (mut local_addr, peer_addr) = match (stream.local_addr(), stream.peer_addr()) {
(Ok(local_addr), Ok(peer_addr)) => (local_addr, peer_addr),
(Err(e), _) | (_, Err(e)) => {
log::warn!("tcp nat get addr error: {e:?}");
continue;
}
};
if no_tun {
let IpAddr::V4(ip) = local_addr.ip() else {
continue;
};
if ip == network.ip().context("not ip")? {
// 虚拟地址未就绪(None)时跳过重写,而不是终止任务
if let Some(net_ip) = network.ip()
&& ip == net_ip
{
// 无tun的情况下写入本机的则写到localhost
local_addr.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST));
}
}
let default_interface = default_interface.clone();
task_group.spawn(async move {
if let Err(e) = stream_task(stream, local_addr).await {
if let Err(e) = stream_task(stream, local_addr, default_interface.as_ref()).await {
log::error!("stream task Error: {:?},{peer_addr}->{local_addr}", e);
}
});
@@ -54,28 +76,31 @@ async fn listen_task(
async fn stream_task(
mut inner_stream: tcp_ip::tcp::TcpStream,
addr: SocketAddr,
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()> {
let mut tokio_stream = TcpStream::connect(addr).await?;
let mut tokio_stream = crate::utils::socket::connect_tcp(addr, default_interface).await?;
tokio::io::copy_bidirectional(&mut inner_stream, &mut tokio_stream).await?;
Ok(())
}
pub(crate) async fn stream_nat<R, W, A: ToSocketAddrs + Debug>(
mut recv_stream: R,
mut send_stream: W,
recv_stream: R,
send_stream: W,
addr: A,
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut tokio_stream = TcpStream::connect(&addr)
let mut tokio_stream = crate::utils::socket::connect_tcp_resolved(addr, default_interface)
.await
.with_context(|| format!("error connecting to {:?}", addr))?;
let (mut tcp_r, mut tcp_w) = tokio_stream.split();
tokio::select! {
_ = tokio::io::copy(&mut recv_stream, &mut tcp_w) => {},
_ = tokio::io::copy(&mut tcp_r, &mut send_stream) => {},
}
.context("error connecting to NAT destination")?;
crate::port_mapping::tcp_port_mapping::copy_bidirectional_split(
&mut tokio_stream,
recv_stream,
send_stream,
)
.await?;
Ok(())
}
+157 -28
View File
@@ -1,7 +1,8 @@
use crate::utils::task_control::TaskGroup;
use crate::utils::task_control::{SubTask, TaskGroup};
use anyhow::Context;
use bytes::Bytes;
use futures::{SinkExt, StreamExt};
use rust_p2p_core::socket::LocalInterface;
use std::collections::HashMap;
use std::fmt::Debug;
use std::net::SocketAddr;
@@ -16,6 +17,8 @@ use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
struct NatEntry {
socket: Arc<tokio::net::UdpSocket>,
last_active: Instant,
/// 反向转发任务,条目过期回收时需要一并终止,否则任务与 socket 永久残留
inbound_task: SubTask,
}
type NatTable = Arc<Mutex<HashMap<(SocketAddr, SocketAddr), NatEntry>>>;
@@ -23,7 +26,11 @@ type NatTable = Arc<Mutex<HashMap<(SocketAddr, SocketAddr), NatEntry>>>;
const NAT_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 5);
const NAT_GC_INTERVAL: Duration = Duration::from_secs(60);
pub async fn start_udp_nat(task_group: &TaskGroup, ip_stack: &IpStack) -> anyhow::Result<()> {
pub async fn start_udp_nat(
task_group: &TaskGroup,
ip_stack: &IpStack,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
let inner_socket = tcp_ip::udp::UdpSocket::bind_all(ip_stack.clone()).await?;
let inner_socket = Arc::new(inner_socket);
let nat_table: NatTable = Arc::new(Mutex::new(HashMap::new()));
@@ -40,8 +47,16 @@ pub async fn start_udp_nat(task_group: &TaskGroup, ip_stack: &IpStack) -> anyhow
}
};
if let Err(e) =
handle_outbound(&group, &inner_socket, &nat_table, src, dst, &buf[..len]).await
if let Err(e) = handle_outbound(
&group,
&inner_socket,
&nat_table,
src,
dst,
&buf[..len],
default_interface.as_ref(),
)
.await
{
log::warn!("udp nat outbound error: {e:?}");
}
@@ -58,6 +73,7 @@ async fn handle_outbound(
src: SocketAddr,
dst: SocketAddr,
packet: &[u8],
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()> {
let key = (src, dst);
@@ -68,19 +84,22 @@ async fn handle_outbound(
entry.socket.clone()
} else {
// 创建真实 UDP socket
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
let bind_addr = if dst.is_ipv4() {
"0.0.0.0:0".parse().expect("valid IPv4 bind address")
} else {
"[::]:0".parse().expect("valid IPv6 bind address")
};
let interface = if dst.ip().is_loopback() {
None
} else {
default_interface
};
let sock = crate::utils::socket::bind_udp(bind_addr, interface)?;
sock.connect(dst).await?;
let sock = Arc::new(sock);
table.insert(
key,
NatEntry {
socket: sock.clone(),
last_active: Instant::now(),
},
);
// 启动反向转发
spawn_inbound(
let inbound_task = spawn_inbound(
task_group,
inner.clone(),
nat.clone(),
@@ -89,6 +108,15 @@ async fn handle_outbound(
sock.clone(),
);
table.insert(
key,
NatEntry {
socket: sock.clone(),
last_active: Instant::now(),
inbound_task,
},
);
sock
}
};
@@ -104,7 +132,7 @@ fn spawn_inbound(
src: SocketAddr,
dst: SocketAddr,
socket: Arc<tokio::net::UdpSocket>,
) {
) -> SubTask {
task_group.spawn(async move {
let mut buf = vec![0u8; 65536];
@@ -125,9 +153,26 @@ fn spawn_inbound(
}
}
// 回收 NAT
nat.lock().await.remove(&(src, dst));
});
// 回收 NAT:仅当表中的条目仍是本任务持有的 socket 时才删除,
// 避免条目过期被 GC 回收并重建后,旧任务误删新条目
let mut table = nat.lock().await;
remove_if_current(&mut table, &(src, dst), &socket);
})
}
/// 仅当映射中的条目仍持有同一个 socket(即仍是当前任务对应的条目)时才删除
fn remove_if_current(
table: &mut HashMap<(SocketAddr, SocketAddr), NatEntry>,
key: &(SocketAddr, SocketAddr),
socket: &Arc<tokio::net::UdpSocket>,
) -> bool {
if let Some(entry) = table.get(key)
&& Arc::ptr_eq(&entry.socket, socket)
{
table.remove(key);
return true;
}
false
}
fn spawn_nat_gc(task_group: &TaskGroup, nat: NatTable) {
@@ -138,15 +183,27 @@ fn spawn_nat_gc(task_group: &TaskGroup, nat: NatTable) {
interval.tick().await;
let now = Instant::now();
let expired_tasks = {
let mut table = nat.lock().await;
let expired_keys: Vec<(SocketAddr, SocketAddr)> = table
.iter()
.filter(|(_, entry)| now.duration_since(entry.last_active) >= NAT_IDLE_TIMEOUT)
.map(|(key, _)| *key)
.collect();
let mut tasks = Vec::with_capacity(expired_keys.len());
for key in expired_keys {
if let Some(entry) = table.remove(&key) {
log::debug!("udp nat expired: {} -> {}", key.0, key.1);
tasks.push(entry.inbound_task);
}
}
tasks
};
table.retain(|(src, dst), entry| {
let alive = now.duration_since(entry.last_active) < NAT_IDLE_TIMEOUT;
if !alive {
log::debug!("udp nat expired: {} -> {}", src, dst);
// 终止过期条目的反向转发任务,释放其持有的 socket
for task in expired_tasks {
task.stop().await;
}
alive
});
}
});
}
@@ -155,16 +212,28 @@ pub(crate) async fn stream_nat<R, W, A: ToSocketAddrs + Debug>(
recv_stream: R,
send_stream: W,
addr: A,
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let udp_socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
udp_socket
.connect(&addr)
.await
.with_context(|| format!("error connecting to {:?}", addr))?;
let destination = tokio::net::lookup_host(addr)
.await?
.next()
.context("UDP NAT destination resolved to no address")?;
let bind_addr = if destination.is_ipv4() {
"0.0.0.0:0".parse().expect("valid IPv4 bind address")
} else {
"[::]:0".parse().expect("valid IPv6 bind address")
};
let interface = if destination.ip().is_loopback() {
None
} else {
default_interface
};
let udp_socket = crate::utils::socket::bind_udp(bind_addr, interface)?;
udp_socket.connect(destination).await?;
let mut framed_read = FramedRead::new(recv_stream, LengthDelimitedCodec::new());
let mut framed_write = FramedWrite::new(send_stream, LengthDelimitedCodec::new());
let mut buf = vec![0u8; 65536];
@@ -186,3 +255,63 @@ where
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::task_control::TaskGroupManager;
async fn new_entry() -> (NatEntry, Arc<tokio::net::UdpSocket>) {
let socket = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
let manager = TaskGroupManager::new();
let (group, _guard) = manager.create_task().unwrap();
let inbound_task = group.spawn(async {});
(
NatEntry {
socket: socket.clone(),
last_active: Instant::now(),
inbound_task,
},
socket,
)
}
fn test_key() -> (SocketAddr, SocketAddr) {
(
"10.0.0.1:1000".parse().unwrap(),
"8.8.8.8:53".parse().unwrap(),
)
}
/// 误删竞态:条目过期被 GC 回收并以新 socket 重建后,
/// 旧任务退出时不允许把新条目删掉。
#[tokio::test]
async fn test_remove_if_current_only_removes_same_socket() {
let key = test_key();
let mut table = HashMap::new();
let (entry, socket) = new_entry().await;
table.insert(key, entry);
// 旧任务持有的 socket 与表中条目不同(条目已重建):不允许删除
let stale_socket = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
assert!(!remove_if_current(&mut table, &key, &stale_socket));
assert!(table.contains_key(&key));
// 同一个 socket(条目确属本任务):允许删除
assert!(remove_if_current(&mut table, &key, &socket));
assert!(!table.contains_key(&key));
}
/// 条目中的 inbound_task 可被正常终止(GC 回收路径依赖此能力释放任务与 socket)
#[tokio::test]
async fn test_entry_inbound_task_stoppable() {
let manager = TaskGroupManager::new();
let (group, _guard) = manager.create_task().unwrap();
let task = group.spawn(async {
tokio::time::sleep(Duration::from_secs(3600)).await;
});
assert!(task.is_running());
task.stop().await;
assert!(!task.is_running());
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
pub(crate) mod internal_nat;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetInput {
pub net: Ipv4Net,
pub target_ip: Ipv4Addr,
+60 -6
View File
@@ -7,8 +7,26 @@ use crate::utils::task_control::TaskGroup;
use anyhow::Context;
use pnet_packet::ip::IpNextHeaderProtocols;
use std::net::{Ipv4Addr, SocketAddr};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::{TcpListener, TcpStream};
/// 双向转发:任一方向 EOF 时对另一端执行 shutdown 并继续转发剩余方向,
/// 直到两个方向都完成。避免 select! 下任一方向先结束就 drop 另一方向
/// 造成的半关闭截断(如对端半关闭后响应数据丢失)。
pub(crate) async fn copy_bidirectional_split<T, R, W>(
stream: &mut T,
reader: R,
writer: W,
) -> anyhow::Result<(u64, u64)>
where
T: AsyncRead + AsyncWrite + Unpin,
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut other = tokio::io::join(reader, writer);
Ok(tokio::io::copy_bidirectional(stream, &mut other).await?)
}
pub async fn start(
task_group: &TaskGroup,
list: &Vec<PortMapping>,
@@ -64,7 +82,7 @@ async fn stream_copy(
dst_port: u16,
quic_tunnel_client: QuicTunnelClient,
) -> anyhow::Result<()> {
let (mut send_stream, mut recv_stream) = quic_tunnel_client.open_bi(target_ip).await?;
let (mut send_stream, recv_stream) = quic_tunnel_client.open_bi(target_ip).await?;
let handshake = QuicProxyHandshake {
handshake: Some(quic_proxy_handshake::Handshake::TcpPortMapping(
PortProxyHandshake {
@@ -76,10 +94,46 @@ async fn stream_copy(
)),
};
send_handshake(&mut send_stream, handshake).await?;
let (mut tcp_r, mut tcp_w) = tcp_stream.split();
tokio::select! {
_ = tokio::io::copy(&mut recv_stream, &mut tcp_w) => {},
_ = tokio::io::copy(&mut tcp_r, &mut send_stream) => {},
}
copy_bidirectional_split(&mut tcp_stream, recv_stream, send_stream).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// 半关闭场景:客户端发完请求后 shutdown 写方向,
/// 服务端的响应必须完整送达,不能被截断。
#[tokio::test]
async fn test_half_close_no_truncation() {
let (mut client, mut relay_tcp) = tokio::io::duplex(64);
let (relay_tunnel, mut server) = tokio::io::duplex(64);
let (relay_tunnel_r, relay_tunnel_w) = tokio::io::split(relay_tunnel);
let relay = tokio::spawn(async move {
copy_bidirectional_split(&mut relay_tcp, relay_tunnel_r, relay_tunnel_w)
.await
.unwrap();
});
// 客户端发请求后立即半关闭写方向
client.write_all(b"ping").await.unwrap();
client.shutdown().await.unwrap();
// 服务端读到完整请求(读到 EOF 前数据不能丢)
let mut buf = [0u8; 4];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"ping");
// 服务端回响应并关闭
server.write_all(b"pong").await.unwrap();
server.shutdown().await.unwrap();
// 客户端必须能读到完整响应(旧实现此处会被截断为空)
let mut out = Vec::new();
client.read_to_end(&mut out).await.unwrap();
assert_eq!(out, b"pong");
relay.await.unwrap();
}
}
+58 -1
View File
@@ -53,7 +53,16 @@ async fn recv(
let mut buf = vec![0u8; 65536];
let dest_map = Arc::new(Mutex::new(HashMap::<SocketAddr, Sender<Bytes>>::new()));
loop {
let (len, src) = udp_socket.recv_from(&mut buf).await?;
// 单次接收错误不能杀掉整个端口映射任务:记录日志后继续,
// 短暂休眠避免持续性错误造成空转
let (len, src) = match udp_socket.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
log::warn!("udp port mapping recv error: {e:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let bytes = Bytes::copy_from_slice(&buf[..len]);
let tx = {
@@ -67,6 +76,8 @@ async fn recv(
let dst_host = mapping.dst_host.clone();
let dst_port = mapping.dst_port;
let tunnel_client = quic_tunnel_client.clone();
let tx_clone = tx.clone();
let dest_map_clone = dest_map.clone();
task_group.spawn(async move {
if let Err(e) = udp_mapping_handle(
udp_socket,
@@ -81,6 +92,9 @@ async fn recv(
{
log::error!("udp_mapping_handle {e:?},src:{src}");
}
// 任务退出(含 60s 空闲超时)时回收映射条目,
// 否则 dest_map 随不同 src 数量无界增长
remove_if_same(&mut dest_map_clone.lock(), &src, &tx_clone);
});
map.insert(src, tx.clone());
@@ -100,6 +114,22 @@ async fn recv(
}
}
/// 仅当映射仍指向同一个 channel(即仍是本任务的条目)时才移除,
/// 避免条目被回收重建后,旧任务误删新条目
fn remove_if_same(
map: &mut HashMap<SocketAddr, Sender<Bytes>>,
src: &SocketAddr,
tx: &Sender<Bytes>,
) -> bool {
if let Some(cur) = map.get(src)
&& cur.same_channel(tx)
{
map.remove(src);
return true;
}
false
}
async fn udp_mapping_handle(
udp_socket: Arc<UdpSocket>,
src: SocketAddr,
@@ -143,3 +173,30 @@ async fn udp_mapping_handle(
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// 空闲回收竞态:条目被重建后,旧任务退出不得误删新条目
#[test]
fn test_remove_if_same() {
let src: SocketAddr = "10.0.0.1:5000".parse().unwrap();
let mut map = HashMap::new();
let (old_tx, _old_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
map.insert(src, old_tx.clone());
// 条目被重建为新 channel
let (new_tx, _new_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
map.insert(src, new_tx);
// 旧任务退出:不允许删除新条目
assert!(!remove_if_same(&mut map, &src, &old_tx));
assert!(map.contains_key(&src));
// 新任务退出:允许删除
let new_tx = map.get(&src).unwrap().clone();
assert!(remove_if_same(&mut map, &src, &new_tx));
assert!(!map.contains_key(&src));
}
}
+88 -3
View File
@@ -2,7 +2,7 @@
0 15 31
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 1 | msg_type(7) |max ttl(4) |curr ttl(4)| C | G | R | reserve(13) |
| 1 | msg_type(7) |max ttl(4) |curr ttl(4)| C | G | F | E | reserve(12) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| seq(32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
@@ -27,7 +27,7 @@ pub struct NetHeader {
pub type_byte: u8,
/// Byte 1: high 4 = max ttl, low 4 = curr ttl
pub ttl_byte: u8,
/// Byte 2: C(0x80) | G(0x40) | reserve
/// Byte 2: C(0x80) | G(0x40) | F(0x20) | ETHERNET(0x10) | reserve
pub flags_byte: u8,
/// Byte 3: reserve
pub _reserved: u8,
@@ -39,6 +39,7 @@ pub struct NetHeader {
const COMPRESSED: u8 = 0x80;
const GATEWAY: u8 = 0x40;
const FEC: u8 = 0x20;
const ETHERNET: u8 = 0x10;
impl NetHeader {
#[inline]
pub fn msg_type(&self) -> u8 {
@@ -106,6 +107,8 @@ pub enum MsgType {
RpcRes = 15,
Quic = 17,
RelayProbe = 18,
RelayProbeReply = 19,
}
impl From<MsgType> for u8 {
fn from(val: MsgType) -> Self {
@@ -139,6 +142,8 @@ impl TryFrom<u8> for MsgType {
15 => MsgType::RpcRes,
17 => MsgType::Quic,
18 => MsgType::RelayProbe,
19 => MsgType::RelayProbeReply,
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -206,6 +211,9 @@ impl<B: AsRef<[u8]>> NetPacket<B> {
pub fn is_fec(&self) -> bool {
(self.header().flags_byte & FEC) != 0
}
pub fn is_ethernet(&self) -> bool {
(self.header().flags_byte & ETHERNET) != 0
}
pub fn head(&self) -> &[u8] {
&self.buffer.as_ref()[..HEAD_LENGTH]
}
@@ -225,7 +233,7 @@ impl<B: AsRef<[u8]> + AsMut<[u8]>> NetPacket<B> {
self.header_mut().set_msg_type(msg_type.into());
}
pub fn decr_ttl(&mut self){
pub fn decr_ttl(&mut self) {
self.header_mut().decr_ttl()
}
@@ -254,6 +262,9 @@ impl<B: AsRef<[u8]> + AsMut<[u8]>> NetPacket<B> {
pub fn set_fec_flag(&mut self, fec: bool) {
self.header_mut().set_flag(FEC, fec);
}
pub fn set_ethernet_flag(&mut self, ethernet: bool) {
self.header_mut().set_flag(ETHERNET, ethernet);
}
pub fn set_payload(&mut self, data: &[u8]) -> io::Result<()> {
let buf = self.buffer.as_mut();
@@ -300,3 +311,77 @@ impl NetPacket<TransmissionBytes> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn msg_type_round_trip() {
let all = [
MsgType::Turn,
MsgType::Broadcast,
MsgType::ExcludeBroadcast,
MsgType::TargetBroadcast,
MsgType::Ping,
MsgType::Pong,
MsgType::PingTurn,
MsgType::PongTurn,
MsgType::PunchStart1,
MsgType::PunchStart2,
MsgType::PunchReq,
MsgType::PunchRes,
MsgType::PushClientIps,
MsgType::RpcReq,
MsgType::RpcRes,
MsgType::Quic,
MsgType::RelayProbe,
MsgType::RelayProbeReply,
];
for msg_type in all {
let byte = u8::from(msg_type);
assert_eq!(
MsgType::try_from(byte).unwrap(),
msg_type,
"round trip failed for {msg_type:?} ({byte})"
);
}
// 未分配的取值必须报错
assert!(MsgType::try_from(0u8).is_err());
assert!(MsgType::try_from(16u8).is_err());
assert!(MsgType::try_from(20u8).is_err());
}
#[test]
fn ethernet_flag_round_trip() {
let mut packet = NetPacket::new(BytesMut::from(&[0u8; HEAD_LENGTH][..])).unwrap();
assert!(!packet.is_ethernet());
packet.set_ethernet_flag(true);
assert!(packet.is_ethernet());
packet.set_fec_flag(true);
assert!(packet.is_ethernet());
packet.set_ethernet_flag(false);
assert!(!packet.is_ethernet());
assert!(packet.is_fec());
}
/// 中继转发语义:包每经过一跳 curr_ttl 减 1curr_ttl >= 1 时才继续转发,
/// 接收方以 metric = max_ttl - curr_ttl 计算路由距离。
#[test]
fn relay_reply_survives_one_hop() {
let mut packet = NetPacket::new(BytesMut::from(&[0u8; HEAD_LENGTH][..])).unwrap();
packet.set_msg_type(MsgType::RelayProbeReply);
// 目标方回复时 TTL 必须允许一次中继
packet.set_ttl(2);
// 中继节点:decr 后 curr_ttl == 1,满足转发条件 ttl >= 1
packet.decr_ttl();
assert_eq!(packet.ttl(), 1);
assert!(packet.ttl() >= 1, "relay node would drop this packet");
// 发起方:decr 后 curr_ttl == 0metric = 2(经由一个中继)
packet.decr_ttl();
assert_eq!(packet.ttl(), 0);
assert_eq!(packet.max_ttl() - packet.ttl(), 2);
}
}
+85 -27
View File
@@ -9,11 +9,14 @@ use std::str::FromStr;
#[derive(Debug)]
pub struct FingerprintVerifier {
pub expected_fingerprint: [u8; 32],
supported_algorithms: rustls::crypto::WebPkiSupportedAlgorithms,
}
impl FingerprintVerifier {
pub fn new(expected_fingerprint: [u8; 32]) -> Self {
Self {
expected_fingerprint,
supported_algorithms: rustls::crypto::ring::default_provider()
.signature_verification_algorithms,
}
}
}
@@ -45,39 +48,26 @@ impl ServerCertVerifier for FingerprintVerifier {
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
// 必须真正验证握手签名:证书本身(由密码确定性生成)是公开信息,
// 只比对指纹而不验签无法抵抗重放真实证书的主动中间人
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported_algorithms)
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
// RSA schemes
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
// ECDSA schemes
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
// EdDSA schemes
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
self.supported_algorithms.supported_schemes()
}
}
#[derive(Debug)]
@@ -202,9 +192,7 @@ impl CertValidationMode {
Ok(std::sync::Arc::new(InsecureVerifier))
}
CertValidationMode::VerifyFingerprint(fingerprint) => {
Ok(std::sync::Arc::new(FingerprintVerifier {
expected_fingerprint: *fingerprint,
}))
Ok(std::sync::Arc::new(FingerprintVerifier::new(*fingerprint)))
}
CertValidationMode::Standard => {
let root_store = load_root_cert()?;
@@ -226,3 +214,73 @@ impl CertValidationMode {
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tls::cert::generate_deterministic_cert;
use rustls::ServerConfig;
use rustls::pki_types::ServerName;
use sha2::{Digest, Sha256};
use std::sync::Arc;
fn fingerprint_of(cert: &CertificateDer<'_>) -> [u8; 32] {
Sha256::digest(cert.as_ref()).into()
}
async fn try_handshake(
server_config: Arc<ServerConfig>,
client_config: Arc<ClientConfig>,
) -> std::io::Result<()> {
let (client_io, server_io) = tokio::io::duplex(8192);
let acceptor = tokio_rustls::TlsAcceptor::from(server_config);
let connector = tokio_rustls::TlsConnector::from(client_config);
let server_name = ServerName::try_from("deterministic-node")
.unwrap()
.to_owned();
let (client, _server) = tokio::join!(
connector.connect(server_name, client_io),
acceptor.accept(server_io),
);
client.map(|_| ())
}
#[tokio::test]
async fn test_fingerprint_handshake() {
let password = "handshake_test_password";
let (cert, key) = generate_deterministic_cert(password).unwrap();
let fingerprint = fingerprint_of(&cert);
let server_config = Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert], key)
.unwrap(),
);
// 正例:指纹匹配且服务端持有对应私钥。
// 修复前 verify_tls13_signature 无条件放行,握手必然成功;
// 修复后走真实验签,只有实现正确才能握手成功。
let client_config = Arc::new(
CertValidationMode::VerifyFingerprint(fingerprint)
.create_tls_client_config()
.unwrap(),
);
try_handshake(server_config.clone(), client_config)
.await
.expect("handshake with matching fingerprint should succeed");
// 反例:指纹不匹配(攻击者证书),握手必须失败
let wrong_fingerprint = [0xABu8; 32];
let client_config = Arc::new(
CertValidationMode::VerifyFingerprint(wrong_fingerprint)
.create_tls_client_config()
.unwrap(),
);
assert!(
try_handshake(server_config, client_config).await.is_err(),
"handshake with mismatched fingerprint should fail"
);
}
}
+119 -3
View File
@@ -1,18 +1,134 @@
use crate::context::NetworkAddr;
use crate::ethernet::strip_ipv4;
use crate::nat::internal_nat::InternalNatInbound;
use crate::protocol::transmission::TransmissionBytes;
use crate::tun::TunDataInbound;
use std::net::Ipv4Addr;
#[derive(Clone)]
pub enum EnhancedTunInbound {
Tun(TunDataInbound),
Tap(TunDataInbound),
Nat(InternalNatInbound),
}
impl EnhancedTunInbound {
pub async fn inbound(&self, data: TransmissionBytes, net: &NetworkAddr) -> anyhow::Result<()> {
pub async fn inbound(
&self,
data: TransmissionBytes,
net: &NetworkAddr,
src_node: Ipv4Addr,
ethernet: bool,
) -> anyhow::Result<()> {
match self {
EnhancedTunInbound::Tun(tun) => tun.send(data, net).await,
EnhancedTunInbound::Nat(nat) => nat.send(&data, net).await,
EnhancedTunInbound::Tun(tun) => {
let data = if ethernet {
let Some(ip) = strip_ipv4(data) else {
return Ok(());
};
ip
} else {
data
};
tun.send_ip(data, net, src_node).await
}
EnhancedTunInbound::Tap(tap) => {
if ethernet {
tap.send_frame(data).await
} else {
tap.send_ip(data, net, src_node).await
}
}
EnhancedTunInbound::Nat(nat) => {
let data = if ethernet {
let Some(ip) = strip_ipv4(data) else {
return Ok(());
};
ip
} else {
data
};
nat.send(&data, net).await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::config::DeviceMode;
use crate::ethernet::{ETHERTYPE_IPV4, parse_frame, wrap_ipv4};
use crate::nat::AllowSubnetExternalRoute;
use crate::protocol::ip_packet_protocol::HEAD_LENGTH;
use crate::tun::{TunDataInbound, tun_channel};
fn network() -> NetworkAddr {
NetworkAddr {
gateway: Ipv4Addr::new(10, 26, 0, 1),
broadcast: Ipv4Addr::new(10, 26, 0, 255),
ip: Ipv4Addr::new(10, 26, 0, 9),
prefix_len: 24,
}
}
fn ipv4(src: Ipv4Addr, dest: Ipv4Addr) -> TransmissionBytes {
let mut packet = TransmissionBytes::with_capacity(HEAD_LENGTH, HEAD_LENGTH + 20);
packet.put(&[0u8; 20]).unwrap();
packet[0] = 0x45;
packet[12..16].copy_from_slice(&src.octets());
packet[16..20].copy_from_slice(&dest.octets());
packet
}
#[tokio::test]
async fn tap_wraps_ip_and_tun_strips_ethernet() {
let net = network();
let src = Ipv4Addr::new(10, 26, 0, 8);
let (tap_tx, mut tap_rx) = tun_channel();
let tap = EnhancedTunInbound::Tap(TunDataInbound::new(
tap_tx,
AllowSubnetExternalRoute::new(vec![]),
DeviceMode::Tap,
));
tap.inbound(ipv4(src, net.ip), &net, src, false)
.await
.unwrap();
let frame = tap_rx.receiver.recv().await.unwrap();
assert_eq!(
parse_frame(frame.as_ref()).unwrap().ethertype,
ETHERTYPE_IPV4
);
let (tun_tx, mut tun_rx) = tun_channel();
let tun = EnhancedTunInbound::Tun(TunDataInbound::new(
tun_tx,
AllowSubnetExternalRoute::new(vec![]),
DeviceMode::Tun,
));
let frame = wrap_ipv4(ipv4(src, net.ip), src, &net).unwrap();
tun.inbound(frame, &net, src, true).await.unwrap();
let packet = tun_rx.receiver.recv().await.unwrap();
assert_eq!(packet[0] >> 4, 4);
assert_eq!(&packet[16..20], &net.ip.octets());
}
#[tokio::test]
async fn tap_keeps_arbitrary_ethernet_frame() {
let net = network();
let src = Ipv4Addr::new(10, 26, 0, 8);
let (tap_tx, mut tap_rx) = tun_channel();
let tap = EnhancedTunInbound::Tap(TunDataInbound::new(
tap_tx,
AllowSubnetExternalRoute::new(vec![]),
DeviceMode::Tap,
));
let mut raw = vec![0u8; 32];
raw[0..6].copy_from_slice(&[0xff; 6]);
raw[12..14].copy_from_slice(&0x88b5u16.to_be_bytes());
tap.inbound(raw.as_slice().into(), &net, src, true)
.await
.unwrap();
assert_eq!(tap_rx.receiver.recv().await.unwrap().as_ref(), raw);
}
}
+107 -34
View File
@@ -1,3 +1,4 @@
use crate::context::config::DeviceMode;
use crate::enhanced_tunnel::outbound::EnhancedOutbound;
use crate::protocol::ip_packet_protocol::HEAD_LENGTH;
use crate::protocol::transmission::TransmissionBytes;
@@ -9,8 +10,10 @@ use std::io;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tokio::sync::mpsc::{Receiver, Sender};
use tun_rs::AsyncDevice;
use tun_rs::async_framed::{Decoder, DeviceFramedRead, DeviceFramedWrite, Encoder};
use tun_rs::{AsyncDevice, DeviceBuilder};
#[cfg(not(target_os = "android"))]
use tun_rs::{DeviceBuilder, Layer};
#[derive(Clone)]
pub struct DeviceIOManager {
@@ -19,16 +22,18 @@ pub struct DeviceIOManager {
}
type DeviceMutex = Arc<tokio::sync::Mutex<(Option<DeviceTask>, Option<(Ipv4Addr, u8)>)>>;
pub struct DeviceTask {
#[cfg_attr(target_os = "android", allow(dead_code))]
device: Arc<AsyncDevice>,
task_recv: SubTask,
task_send: SubTask,
task: SubTask,
}
#[derive(Debug, Default)]
pub struct DeviceConfig {
pub device_mode: DeviceMode,
pub tun_name: Option<String>,
#[cfg(unix)]
pub tun_fd: Option<i32>,
pub mtu: Option<u16>,
pub mac_addr: Option<[u8; 6]>,
}
impl DeviceConfig {
@@ -45,6 +50,14 @@ impl DeviceConfig {
self.mtu = Some(mtu);
self
}
pub fn set_device_mode(mut self, device_mode: DeviceMode) -> Self {
self.device_mode = device_mode;
self
}
pub fn set_mac_addr(mut self, mac_addr: [u8; 6]) -> Self {
self.mac_addr = Some(mac_addr);
self
}
}
#[derive(Clone)]
pub struct TunInbound {
@@ -52,7 +65,7 @@ pub struct TunInbound {
}
pub struct TunReceiver {
receiver: Receiver<TransmissionBytes>,
pub(crate) receiver: Receiver<TransmissionBytes>,
}
pub fn tun_channel() -> (TunInbound, TunReceiver) {
let (sender, receiver) = tokio::sync::mpsc::channel(1024);
@@ -69,28 +82,37 @@ impl DeviceIOManager {
pub async fn stop_task(&self) {
let mut guard = self.device.lock().await;
if let Some(dev) = guard.0.take() {
dev.task_recv.stop().await;
dev.task_send.stop().await;
dev.task.stop().await;
}
}
pub async fn start_task(
&self,
device_config: DeviceConfig,
receiver: TunReceiver,
enhanced_outbound: EnhancedOutbound,
receiver: &mut Option<TunReceiver>,
enhanced_outbound: &mut Option<EnhancedOutbound>,
) -> anyhow::Result<()> {
if receiver.is_none() || enhanced_outbound.is_none() {
bail!("device task already started");
}
self.stop_task().await;
// 先执行可能失败的 TUN/TAP 设备创建,成功后才消费 receiver/outbound
// 保证失败时调用方状态完整、可以重试
let device_mode = device_config.device_mode;
let device = Arc::new(create_device(device_config)?);
let receiver = receiver.take().unwrap();
let enhanced_outbound = enhanced_outbound.take().unwrap();
let task = create(
&self.task_group,
device_config,
device,
receiver.receiver,
enhanced_outbound,
)?;
device_mode,
);
self.device.lock().await.0.replace(task);
Ok(())
}
#[cfg(not(target_os = "android"))]
pub async fn tun_if_index(&self) -> anyhow::Result<u32> {
pub async fn device_if_index(&self) -> anyhow::Result<u32> {
let guard = self.device.lock().await;
if let Some(v) = &guard.0 {
Ok(v.device.if_index()?)
@@ -98,10 +120,11 @@ impl DeviceIOManager {
bail!("device doesn't exist")
}
}
#[cfg(not(target_os = "android"))]
pub async fn set_network(&self, ip: Ipv4Addr, prefix_len: u8) -> anyhow::Result<()> {
let mut guard = self.device.lock().await;
let Some(dev) = guard.0.as_ref() else {
bail!("未启动tun")
bail!("虚拟网卡尚未启动")
};
if let Some(v) = guard.1.as_ref()
&& v.0 == ip
@@ -117,7 +140,18 @@ impl DeviceIOManager {
}
}
fn create_tun(config: DeviceConfig) -> anyhow::Result<AsyncDevice> {
fn create_device(config: DeviceConfig) -> anyhow::Result<AsyncDevice> {
#[cfg(target_os = "android")]
{
let fd = config
.tun_fd
.context("Android requires a VpnService TUN fd")?;
// SAFETY: The fd comes directly from ParcelFileDescriptor returned by
// VpnService.Builder.establish and remains open for the network lifetime.
return unsafe { Ok(AsyncDevice::from_fd(fd)?) };
}
#[cfg(not(target_os = "android"))]
{
#[cfg(unix)]
if let Some(fd) = config.tun_fd {
// SAFETY: Caller must ensure fd is a valid, open file descriptor for a TUN device.
@@ -125,57 +159,88 @@ fn create_tun(config: DeviceConfig) -> anyhow::Result<AsyncDevice> {
unsafe { return Ok(AsyncDevice::from_fd(fd)?) }
}
let mut builder = DeviceBuilder::new();
builder = builder.layer(match config.device_mode {
DeviceMode::Tap => Layer::L2,
DeviceMode::Tun => Layer::L3,
DeviceMode::No => bail!("cannot create a device in no mode"),
});
if let Some(tun_name) = config.tun_name {
builder = builder.name(tun_name);
}
if let Some(mtu) = config.mtu {
builder = builder.mtu(mtu);
}
#[cfg(any(
target_os = "windows",
target_os = "linux",
target_os = "freebsd",
target_os = "openbsd",
target_os = "macos",
target_os = "netbsd"
))]
if let Some(mac_addr) = config.mac_addr {
builder = builder.mac_addr(mac_addr);
}
#[cfg(windows)]
{
builder = builder.metric(1);
}
#[cfg(target_os = "linux")]
{
if config.device_mode == DeviceMode::Tun {
builder = builder.offload(true);
}
let dev = builder.build_async().context("创建tun失败")?;
let dev = builder.build_async().with_context(|| {
if config.device_mode == DeviceMode::Tap && cfg!(windows) {
"创建 TAP 失败;Windows TAP 模式需要预先安装 tap-windows (tap0901) 驱动"
} else if config.device_mode == DeviceMode::Tap {
"创建 TAP 失败"
} else {
"创建 TUN 失败"
}
})?;
#[cfg(target_os = "linux")]
{
_ = dev.set_tx_queue_len(1000);
}
Ok(dev)
}
}
fn create(
task_group: &TaskGroup,
config: DeviceConfig,
device: Arc<AsyncDevice>,
receiver: Receiver<TransmissionBytes>,
enhanced_outbound: EnhancedOutbound,
) -> anyhow::Result<DeviceTask> {
let device = Arc::new(create_tun(config)?);
device_mode: DeviceMode,
) -> DeviceTask {
let device_framed_read = DeviceFramedRead::new(device.clone(), BytesCodec::new());
let device_framed_write = DeviceFramedWrite::new(device.clone(), BytesCodec::new());
let outbound_device = device.clone();
let task_recv = task_group.spawn(async move {
if let Err(e) = in_tun_loop(receiver, device_framed_write).await {
log::error!("in_tun_loop error: {e:?}")
// 读写两个方向合并为一个任务:任一方向结束(出错或设备关闭)即
// 通过 select! 取消另一方向,避免单侧失败后另一侧继续运行的半开状态
let task = task_group.spawn(async move {
tokio::select! {
rs = in_device_loop(receiver, device_framed_write) => {
if let Err(e) = rs {
log::error!("in_device_loop error, stopping out_device_loop: {e:?}");
} else {
log::warn!("in_device_loop exited, stopping out_device_loop");
}
}
rs = out_device_loop(device_framed_read, outbound_device, enhanced_outbound, device_mode) => {
if let Err(e) = rs {
log::error!("out_device_loop error, stopping in_device_loop: {e:?}");
} else {
log::warn!("out_device_loop exited, stopping in_device_loop");
}
}
});
let task_send = task_group.spawn(async move {
if let Err(e) = out_tun_loop(device_framed_read, enhanced_outbound).await {
log::error!("out_tun_loop error: {e:?}");
}
});
Ok(DeviceTask {
device,
task_recv,
task_send,
})
DeviceTask { device, task }
}
async fn in_tun_loop(
async fn in_device_loop(
mut receiver: Receiver<TransmissionBytes>,
mut device_framed_write: DeviceFramedWrite<BytesCodec, Arc<AsyncDevice>>,
) -> anyhow::Result<()> {
@@ -183,7 +248,7 @@ async fn in_tun_loop(
match device_framed_write.send(data).await {
Ok(_) => {}
Err(e) => {
log::error!("send to tun error: {:?}", e);
log::error!("send to virtual device error: {:?}", e);
return Err(anyhow::anyhow!(e));
}
}
@@ -191,14 +256,22 @@ async fn in_tun_loop(
Ok(())
}
async fn out_tun_loop(
async fn out_device_loop(
mut device_framed_read: DeviceFramedRead<BytesCodec, Arc<AsyncDevice>>,
device: Arc<AsyncDevice>,
enhanced_outbound: EnhancedOutbound,
device_mode: DeviceMode,
) -> anyhow::Result<()> {
while let Some(rs) = device_framed_read.next().await {
let bytes_mut = rs?;
if device_mode == DeviceMode::Tap {
if let Some(reply) = enhanced_outbound.ethernet_outbound(bytes_mut).await {
device.send(reply.as_ref()).await?;
}
} else {
enhanced_outbound.ipv4_outbound(bytes_mut).await;
}
}
Ok(())
}
+77 -3
View File
@@ -1,26 +1,40 @@
use crate::context::NetworkAddr;
use crate::context::config::DeviceMode;
use crate::ethernet::wrap_ipv4;
use crate::nat::AllowSubnetExternalRoute;
use crate::protocol::transmission::TransmissionBytes;
use crate::tun::TunInbound;
use pnet_packet::ipv4::Ipv4Packet;
use std::net::Ipv4Addr;
#[derive(Clone)]
pub struct TunDataInbound {
allow_subnet: AllowSubnetExternalRoute,
tun_inbound: TunInbound,
device_mode: DeviceMode,
}
impl TunDataInbound {
pub fn new(tun_inbound: TunInbound, allow_subnet: AllowSubnetExternalRoute) -> Self {
pub fn new(
tun_inbound: TunInbound,
allow_subnet: AllowSubnetExternalRoute,
device_mode: DeviceMode,
) -> Self {
Self {
allow_subnet,
tun_inbound,
device_mode,
}
}
}
impl TunDataInbound {
pub async fn send(&self, data: TransmissionBytes, net: &NetworkAddr) -> anyhow::Result<()> {
if data[0] >> 4 != 4 {
pub async fn send_ip(
&self,
data: TransmissionBytes,
net: &NetworkAddr,
src_node: Ipv4Addr,
) -> anyhow::Result<()> {
if data.is_empty() || data[0] >> 4 != 4 {
return Ok(());
}
let Some(ipv4) = Ipv4Packet::new(data.as_ref()) else {
@@ -33,8 +47,68 @@ impl TunDataInbound {
|| dest.is_multicast()
|| self.allow_subnet.allow(&dest)
{
let data = if self.device_mode == DeviceMode::Tap {
let Some(frame) = wrap_ipv4(data, src_node, net) else {
return Ok(());
};
frame
} else {
data
};
self.tun_inbound.sender.send(data).await?;
}
Ok(())
}
pub async fn send_frame(&self, data: TransmissionBytes) -> anyhow::Result<()> {
self.tun_inbound.sender.send(data).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nat::AllowSubnetExternalRoute;
use crate::tun::tun_channel;
fn test_net() -> NetworkAddr {
NetworkAddr {
gateway: Ipv4Addr::new(10, 26, 0, 1),
broadcast: Ipv4Addr::new(10, 26, 0, 255),
ip: Ipv4Addr::new(10, 26, 0, 2),
prefix_len: 24,
}
}
/// 对端构造的零载荷/畸形包必须被静默丢弃,不能 panic 杀死数据面任务
#[tokio::test]
async fn test_send_empty_or_short_packet_does_not_panic() {
let (tun_inbound, _receiver) = tun_channel();
let inbound = TunDataInbound::new(
tun_inbound,
AllowSubnetExternalRoute::new(vec![]),
DeviceMode::Tun,
);
// 零载荷包(头部被剥离后为空)
inbound
.send_ip(
TransmissionBytes::zeroed(0),
&test_net(),
Ipv4Addr::new(10, 26, 0, 3),
)
.await
.unwrap();
// 过短的包(不足 IPv4 头)
inbound
.send_ip(
TransmissionBytes::zeroed(3),
&test_net(),
Ipv4Addr::new(10, 26, 0, 3),
)
.await
.unwrap();
}
}
+85
View File
@@ -234,6 +234,60 @@ impl HybridOutbound {
self.traffic_stats.record_tx(dest, len);
Ok(())
}
pub async fn ethernet_ipv4_outbound(
&self,
net: NetworkAddr,
data: TransmissionBytes,
mut dest: Ipv4Addr,
) -> anyhow::Result<()> {
if dest == net.gateway {
let Some(ip) = crate::ethernet::strip_ipv4(data) else {
return Ok(());
};
return self.ipv4_gateway_outbound(net, ip).await;
}
if dest.is_multicast() || dest == net.broadcast || dest.is_broadcast() {
return self.ethernet_broadcast_outbound(net, data).await;
}
if !net.network().contains(&dest) {
if let Some(route) = self.external_route.route(&dest) {
dest = route;
} else {
return Ok(());
}
}
self.ethernet_unicast_outbound(net, dest, data).await
}
pub async fn ethernet_unicast_outbound(
&self,
net: NetworkAddr,
dest: Ipv4Addr,
mut data: TransmissionBytes,
) -> anyhow::Result<()> {
let len = data.len() as u64;
data.retreat_head(HEAD_LENGTH)?;
let mut packet = NetPacket::new(data)?;
packet.set_msg_type(MsgType::Turn);
packet.set_src_id(net.ip.into());
packet.set_dest_id(dest.into());
packet.set_ttl(5);
packet.set_ethernet_flag(true);
let packet = self
.packet_compression
.compress(packet, self.basic_outbound.encrypt_reserve())?;
let packet = if let Some(fec_encoder) = &self.fec_encoder {
fec_encoder.encode(packet)?
} else {
packet
};
self.basic_outbound
.send_encrypted_packet(dest, packet)
.await?;
self.traffic_stats.record_tx(dest, len);
Ok(())
}
pub async fn ipv4_gateway_outbound(
&self,
net: NetworkAddr,
@@ -279,6 +333,37 @@ impl HybridOutbound {
.send_raw_broadcast(exclude_ips, packet_bytes)
.await
}
pub async fn ethernet_broadcast_outbound(
&self,
net: NetworkAddr,
mut data: TransmissionBytes,
) -> anyhow::Result<()> {
data.retreat_head(HEAD_LENGTH)?;
let mut packet = NetPacket::new(data)?;
packet.set_msg_type(MsgType::Broadcast);
packet.set_src_id(net.ip.into());
packet.set_dest_id(Ipv4Addr::BROADCAST.into());
packet.set_ttl(5);
packet.set_ethernet_flag(true);
let mut packet = self
.packet_compression
.compress(packet, self.basic_outbound.encrypt_reserve())?;
self.basic_outbound.encrypt_in_place(&mut packet)?;
let packet_bytes = packet.into_bytes();
let list = self.server_info.client_online_ips();
let exclude_ips = self
.basic_outbound
.p2p_broadcast_transmission(&list, 16, &packet_bytes);
if let Some(exclude_ips) = &exclude_ips
&& exclude_ips.len() == list.len()
{
return Ok(());
}
self.basic_outbound
.send_raw_broadcast(exclude_ips, packet_bytes)
.await
}
#[allow(dead_code)]
pub fn has_route(&self, dest: &Ipv4Addr) -> bool {
self.basic_outbound.exists_route(dest)
+31 -2
View File
@@ -201,11 +201,17 @@ impl P2pInboundHandler {
let time = i64::from_be_bytes(net_packet.payload()[..8].try_into()?);
let now = crate::utils::time::now_ts_ms();
if now >= time {
// 记录接收并获取丢包率
let loss_rate_f64 = self
.packet_loss_stats
.record_received(ctx.src_ip, route_key);
// 转换为万分率
let loss_rate = (loss_rate_f64 * 10000.0).round() as u16;
self.route_table.add_route(
ctx.src_ip,
Route::from(route_key, metric, (now - time) as _),
Route::from_with_loss(route_key, metric, (now - time) as _, loss_rate),
);
self.packet_loss_stats.record_received(ctx.src_ip);
}
}
}
@@ -255,6 +261,29 @@ impl P2pInboundHandler {
}
MsgType::PingTurn => {}
MsgType::PongTurn => {}
MsgType::RelayProbe => {
let metric = ctx.max_ttl - ctx.ttl;
self.route_table
.add_route_if_absent(ctx.src_ip, Route::from_default_rt(route_key, metric));
let mut packet = NetPacket::new(TransmissionBytes::zeroed_size(
HEAD_LENGTH,
self.packet_crypto.encrypt_reserve(),
))?;
packet.set_msg_type(MsgType::RelayProbeReply);
// 与 RelayProbe 对称:允许中继一次,到达发起方时 curr_ttl 为 0metric = 2
packet.set_ttl(2);
packet.set_src_id(ctx.dest_ip.into());
packet.set_dest_id(ctx.src_ip.into());
self.packet_crypto.encrypt_in_place(&mut packet)?;
tunnel
.send_to(packet.into_bytes().into_buffer(), route_key.addr())
.await?;
}
MsgType::RelayProbeReply => {
let metric = ctx.max_ttl - ctx.ttl;
self.route_table
.add_route(ctx.src_ip, Route::from_default_rt(route_key, metric));
}
_ => {}
}
Ok(())
+4 -1
View File
@@ -68,7 +68,10 @@ impl P2pOutbound {
self.route_table.get_route_by_id(id).ok()
}
pub fn get_p2p_route_by_id(&self, id: &Ipv4Addr) -> Option<Route> {
self.route_table.get_route_by_id(id).ok().filter(|v| v.is_direct())
self.route_table
.get_route_by_id(id)
.ok()
.filter(|v| v.is_direct())
}
pub fn exists_route_by_id(&self, id: &Ipv4Addr) -> bool {
self.route_table.exists(id)
+99 -24
View File
@@ -1,30 +1,52 @@
use parking_lot::{Mutex, RwLock};
use rust_p2p_core::route::{RouteKey, DEFAULT_RTT};
use rust_p2p_core::route::{DEFAULT_RTT, RouteKey};
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::Arc;
use std::time::Instant;
#[derive(Copy, Clone, Debug)]
pub struct Route {
route_key: RouteKey,
metric: u8,
rtt: u32,
/// 丢包率,万分率(0-1000010000 表示 100% 丢包)
loss_rate: u16,
/// 路由评分
score: u32,
}
impl Route {
pub fn from(route_key: RouteKey, metric: u8, rtt: u32) -> Self {
let is_relay = metric > 1;
let score = get_channel_score(rtt, 0, is_relay);
Self {
route_key,
metric,
rtt,
loss_rate: 0,
score,
}
}
pub fn from_with_loss(route_key: RouteKey, metric: u8, rtt: u32, loss_rate: u16) -> Self {
let is_relay = metric > 1;
let score = get_channel_score(rtt, loss_rate as u32, is_relay);
Self {
route_key,
metric,
rtt,
loss_rate,
score,
}
}
pub fn from_default_rt(route_key: RouteKey, metric: u8) -> Self {
let is_relay = metric > 1;
let score = get_channel_score(DEFAULT_RTT, 0, is_relay);
Self {
route_key,
metric,
rtt: DEFAULT_RTT,
loss_rate: 0,
score,
}
}
pub fn route_key(&self) -> RouteKey {
@@ -40,6 +62,39 @@ impl Route {
pub fn metric(&self) -> u8 {
self.metric
}
pub fn loss_rate(&self) -> u16 {
self.loss_rate
}
pub fn score(&self) -> u32 {
self.score
}
}
/// 计算路由评分
///
/// # 参数
/// - `rtt`: 往返时延(毫秒)
/// - `loss_v`: 丢包率(万分率,0-10000
/// - `is_relay`: 是否为中继路由
///
/// # 返回
/// 评分值,越高表示路由质量越好
pub fn get_channel_score(rtt: u32, loss_v: u32, is_relay: bool) -> u32 {
let rtt = rtt.max(1);
let loss_v = loss_v.min(10000);
// 权重配置
let weight = if is_relay { 100 } else { 120 };
let k_adj = 10; // 丢包惩罚系数
// 用 u64 计算避免溢出:分母最大 rtt * 110000rtt > 约39s 时 u32 溢出
// 分子:代表"有效做功"的放大值
let numerator = weight as u64 * (10000 - loss_v) as u64 * 100;
// 分母:代表"链路阻力"
let denominator = rtt as u64 * (10000 + loss_v * k_adj) as u64;
(numerator / denominator).min(u32::MAX as u64) as u32
}
#[derive(Clone)]
@@ -143,18 +198,18 @@ impl RouteTable {
}
}
if let Some(owner_id) = owner_map.get(route_key) {
if owner_id == id {
if let Some(owner_id) = owner_map.get(route_key)
&& owner_id == id
{
owner_map.remove(route_key);
}
}
time_map.remove(&(*id, *route_key));
}
/// 移除过期的路由
pub fn remove_oldest_route(&self, expired_time: Instant) {
self.inner.remove_oldest_route(expired_time);
pub fn remove_oldest_route(&self, expired_time: Instant) -> Vec<(Ipv4Addr, RouteKey)> {
self.inner.remove_oldest_route(expired_time)
}
}
@@ -198,27 +253,27 @@ impl RouteTableInner {
let list = guard.entry(id).or_insert_with(|| Vec::with_capacity(6));
// 如果路由已存,更新并重新排序
// 如果路由已存,更新并重新排序
if let Some(idx) = list.iter().position(|v| v.route_key() == key) {
list[idx] = route;
// 向前冒泡(如果 RTT 更小
// 向前冒泡(如果评分更高
let mut i = idx;
while i > 0 && list[i].rtt() < list[i - 1].rtt() {
while i > 0 && list[i].score() > list[i - 1].score() {
list.swap(i, i - 1);
i -= 1;
}
// 向后冒泡(如果 RTT 更大
while i + 1 < list.len() && list[i].rtt() > list[i + 1].rtt() {
// 向后冒泡(如果评分更低
while i + 1 < list.len() && list[i].score() < list[i + 1].score() {
list.swap(i, i + 1);
i += 1;
}
return;
}
// 插入新路由,保持按 RTT 排序
// 插入新路由,保持按评分降序排序(评分高的在前)
let mut pos = list.len();
for (i, r) in list.iter().enumerate() {
if route.rtt() < r.rtt() {
if route.score() > r.score() {
pos = i;
break;
}
@@ -226,7 +281,7 @@ impl RouteTableInner {
list.insert(pos, route);
}
fn remove_oldest_route(&self, expired_time: Instant) {
fn remove_oldest_route(&self, expired_time: Instant) -> Vec<(Ipv4Addr, RouteKey)> {
let mut expired_keys = Vec::new();
{
let mut time_map = self.route_key_time.lock();
@@ -241,25 +296,45 @@ impl RouteTableInner {
}
if expired_keys.is_empty() {
return;
return expired_keys;
}
let mut table = self.route_table.write();
let mut owner_map = self.route_key_owner.lock();
for (id, route_key) in expired_keys {
if let Some(list) = table.get_mut(&id) {
list.retain(|r| r.route_key() != route_key);
for (id, route_key) in &expired_keys {
if let Some(list) = table.get_mut(id) {
list.retain(|r| r.route_key() != *route_key);
if list.is_empty() {
table.remove(&id);
table.remove(id);
}
}
if let Some(owner_id) = owner_map.get(&route_key) {
if *owner_id == id {
owner_map.remove(&route_key);
}
if let Some(owner_id) = owner_map.get(route_key)
&& *owner_id == *id
{
owner_map.remove(route_key);
}
}
expired_keys
}
}
#[cfg(test)]
mod tests {
use super::*;
/// rtt 极大(>39s)时分母不能 u32 溢出(debug 构建下溢出会 panic
#[test]
fn test_get_channel_score_large_rtt_no_overflow() {
let score = get_channel_score(u32::MAX, 0, false);
assert_eq!(score, 0);
let score = get_channel_score(u32::MAX, 10000, true);
assert_eq!(score, 0);
// 正常值结果与预期一致:低 rtt 零丢包得分高
let good = get_channel_score(10, 0, false);
let bad = get_channel_score(1000, 5000, true);
assert!(good > bad);
}
}
@@ -1,5 +1,6 @@
use crate::context::AppState;
use rust_p2p_core::nat::{NatInfo, NatType};
use rust_p2p_core::socket::LocalInterface;
use rust_p2p_core::tunnel::SocketManager;
use rust_p2p_core::tunnel::udp::Model;
use std::collections::HashMap;
@@ -9,19 +10,38 @@ use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub async fn my_nat_info(app_context: AppState, socket_manager: SocketManager) {
pub async fn my_nat_info(
app_context: AppState,
socket_manager: SocketManager,
default_interface: Option<LocalInterface>,
outbound_interface_name: Option<String>,
) {
loop {
my_nat_info_impl(&app_context, &socket_manager).await;
my_nat_info_impl(
&app_context,
&socket_manager,
default_interface.as_ref(),
outbound_interface_name.as_deref(),
)
.await;
tokio::time::sleep(Duration::from_secs(60 * 30)).await;
}
}
async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager) {
async fn my_nat_info_impl(
app_context: &AppState,
socket_manager: &SocketManager,
default_interface: Option<&LocalInterface>,
outbound_interface_name: Option<&str>,
) {
let network = app_context.network.network();
let mut local_ipv4s = Vec::new();
let mut local_ipv6 = Vec::new();
match getifaddrs::getifaddrs() {
Ok(addrs) => {
for x in addrs {
if outbound_interface_name.is_some_and(|name| x.name != name) {
continue;
}
let Some(ip) = x.address.ip_addr() else {
continue;
};
@@ -67,17 +87,28 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
}
}
log::info!("local_ipv4s: {:?}", local_ipv4s);
let local_ipv4 = rust_p2p_core::extend::addr::local_ipv4()
let detected = if outbound_interface_name.is_none() {
rust_p2p_core::extend::addr::local_ipv4()
.await
.unwrap_or_else(|e| {
.map_err(|e| {
log::warn!("local ipv4 failed {e:?}");
local_ipv4s
.first()
.cloned()
.unwrap_or(Ipv4Addr::UNSPECIFIED)
});
local_ipv4s = vec![local_ipv4];
let mut ipv6 = rust_p2p_core::extend::addr::local_ipv6().await.ok();
e
})
.ok()
} else {
None
};
let Some((local_ipv4, merged)) = select_local_ipv4(detected, &local_ipv4s) else {
log::warn!("未发现可用本机 IPv4 地址,跳过本次 NAT 信息更新");
return;
};
// 保留网卡扫描结果,主地址排在首位
local_ipv4s = merged;
let mut ipv6 = if outbound_interface_name.is_none() {
rust_p2p_core::extend::addr::local_ipv6().await.ok()
} else {
local_ipv6.first().cloned()
};
if let Some(addr) = ipv6 {
if addr.is_loopback()
|| addr.is_unique_local()
@@ -90,16 +121,24 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
} else {
ipv6 = local_ipv6.first().cloned();
}
let local_udp_ports = socket_manager
.udp_socket_manager_as_ref()
.unwrap()
.local_ports()
.unwrap();
let Some(udp_mgr) = socket_manager.udp_socket_manager_as_ref() else {
log::warn!("udp socket manager 未就绪,跳过本次 NAT 信息更新");
return;
};
let local_udp_ports = match udp_mgr.local_ports() {
Ok(ports) => ports,
Err(e) => {
log::warn!("获取本地 UDP 端口失败: {e:?},跳过本次 NAT 信息更新");
return;
}
};
let local_tcp_port = socket_manager
.tcp_socket_manager_as_ref()
.unwrap()
.local_addr()
.port();
.map(|m| m.local_addr().port())
.unwrap_or_else(|| {
log::warn!("tcp socket manager 未就绪,local_tcp_port 置 0");
0
});
log::info!(
"local_ipv4={local_ipv4},ipv6={ipv6:?},local_udp_ports:{local_udp_ports:?},local_tcp_port:{local_tcp_port:?}"
);
@@ -123,7 +162,8 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
if stun_server.is_empty() {
stun_server = default_udp_stun();
}
let (nat_type, public_ips, port_range) = rust_p2p_core::stun::stun_test_nat(stun_server, None)
let (nat_type, public_ips, port_range) =
rust_p2p_core::stun::stun_test_nat(stun_server, default_interface)
.await
.unwrap_or_else(|e| {
log::warn!("stun_test_nat {e:?}");
@@ -138,15 +178,26 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
NatType::Cone => Model::Low,
NatType::Symmetric => Model::High,
};
if let Err(e) = socket_manager
.udp_socket_manager_as_ref()
.unwrap()
.switch_model(model)
{
if let Err(e) = udp_mgr.switch_model(model) {
log::error!("switch_model error: {e:?}");
}
}
/// 确定本机主 IPv4 并把它合并到地址列表首位。
/// detected 为路由探测得到的主地址(可能不可用),scanned 为网卡扫描结果。
/// 返回 None 表示没有任何可用地址,调用方应跳过本次发布,避免上报 0.0.0.0。
fn select_local_ipv4(
detected: Option<Ipv4Addr>,
scanned: &[Ipv4Addr],
) -> Option<(Ipv4Addr, Vec<Ipv4Addr>)> {
let primary = detected
.filter(|ip| !ip.is_unspecified())
.or_else(|| scanned.first().copied())?;
let mut list: Vec<Ipv4Addr> = scanned.iter().copied().filter(|a| *a != primary).collect();
list.insert(0, primary);
Some((primary, list))
}
pub async fn query_udp_public_addr_loop(app_context: AppState, socket_manager: SocketManager) {
let mut udp_stun_servers = app_context.udp_stun();
if udp_stun_servers.is_empty() {
@@ -187,7 +238,7 @@ pub(crate) async fn query_tcp_public_addr_loop(
app_context: AppState,
socket_manager: SocketManager,
) {
use rand::Rng;
use rand::RngExt;
use rand::seq::SliceRandom;
let tcp_stun_servers = {
@@ -385,3 +436,51 @@ fn default_tcp_stun() -> Vec<String> {
"stun.nextcloud.com:443".to_string(),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> Ipv4Addr {
s.parse().unwrap()
}
/// 探测地址有效时:主地址在首位,扫描结果保留
#[test]
fn test_select_local_ipv4_detected_valid() {
let scanned = vec![ip("192.168.1.2"), ip("10.0.0.3")];
let (primary, list) = select_local_ipv4(Some(ip("172.16.0.2")), &scanned).unwrap();
assert_eq!(primary, ip("172.16.0.2"));
assert_eq!(
list,
vec![ip("172.16.0.2"), ip("192.168.1.2"), ip("10.0.0.3")]
);
}
/// 探测失败/0.0.0.0 时:回退到扫描结果首个地址
#[test]
fn test_select_local_ipv4_fallback_to_scanned() {
let scanned = vec![ip("192.168.1.2"), ip("10.0.0.3")];
let (primary, list) = select_local_ipv4(None, &scanned).unwrap();
assert_eq!(primary, ip("192.168.1.2"));
assert_eq!(list, scanned);
let (primary, _) = select_local_ipv4(Some(Ipv4Addr::UNSPECIFIED), &scanned).unwrap();
assert_eq!(primary, ip("192.168.1.2"));
}
/// 探测地址已在扫描结果中时不重复
#[test]
fn test_select_local_ipv4_no_duplicate() {
let scanned = vec![ip("192.168.1.2"), ip("10.0.0.3")];
let (_, list) = select_local_ipv4(Some(ip("10.0.0.3")), &scanned).unwrap();
assert_eq!(list, vec![ip("10.0.0.3"), ip("192.168.1.2")]);
}
/// 两者皆空:返回 None,调用方跳过发布,不会上报 0.0.0.0
#[test]
fn test_select_local_ipv4_none_when_empty() {
assert!(select_local_ipv4(None, &[]).is_none());
assert!(select_local_ipv4(Some(Ipv4Addr::UNSPECIFIED), &[]).is_none());
}
}
+48 -31
View File
@@ -13,6 +13,7 @@ use crate::tunnel_core::p2p::transport::punch::{PunchTaskContext, punch_task};
use crate::tunnel_core::server::outbound::ServerOutbound;
use crate::utils::task_control::TaskGroup;
use rust_p2p_core::punch::Puncher;
use rust_p2p_core::socket::LocalInterface;
use rust_p2p_core::tunnel::{Tunnel, TunnelDispatcher, new_tunnel_component};
use std::net::Ipv4Addr;
use std::sync::Arc;
@@ -23,14 +24,30 @@ pub async fn init_tunnel(
app_state: AppState,
tunnel_to_server: ServerOutbound,
packet_crypto: PacketCrypto,
tunnel_port: Option<u16>,
default_interface: Option<LocalInterface>,
outbound_interface_name: Option<String>,
) -> anyhow::Result<(Puncher, P2pOutbound, P2pTask)> {
let udp_config = rust_p2p_core::tunnel::config::UdpTunnelConfig::default()
let tunnel_port = tunnel_port.unwrap_or(0);
let mut udp_config = rust_p2p_core::tunnel::config::UdpTunnelConfig::default()
.set_main_udp_count(2)
.set_sub_udp_count(82);
let tcp_config = rust_p2p_core::tunnel::config::TcpTunnelConfig::new(Box::new(
.set_sub_udp_count(82)
.set_simple_udp_port(tunnel_port);
let mut tcp_config = rust_p2p_core::tunnel::config::TcpTunnelConfig::new(Box::new(
rust_p2p_core::tunnel::tcp::LengthPrefixedInitCodec,
))
.set_tcp_multiplexing_limit(2);
.set_tcp_multiplexing_limit(2)
.set_tcp_port(tunnel_port);
if let Some(interface) = default_interface.clone() {
// rust-p2p-core 当前的接口绑定实现针对 IPv4;指定出口网卡时关闭
// 未绑定的 IPv6 Socket,避免流量绕过所选网卡。
udp_config = udp_config
.set_default_interface(interface.clone())
.set_use_v6(false);
tcp_config = tcp_config
.set_default_interface(interface)
.set_use_v6(false);
}
let config = rust_p2p_core::tunnel::config::TunnelConfig::empty()
.set_udp_tunnel_config(udp_config)
.set_tcp_tunnel_config(tcp_config);
@@ -44,6 +61,8 @@ pub async fn init_tunnel(
task_group.spawn(my_nat_info(
app_state.clone(),
tunnel_dispatcher.socket_manager(),
default_interface,
outbound_interface_name,
));
let manager = tunnel_dispatcher.socket_manager();
task_group.spawn(query_udp_public_addr_loop(
@@ -52,7 +71,10 @@ pub async fn init_tunnel(
));
task_group.spawn(query_tcp_public_addr_loop(app_state.clone(), manager));
task_group.spawn(route_timeout_task(route_table.clone()));
task_group.spawn(route_timeout_task(
route_table.clone(),
app_state.packet_loss_stats.clone(),
));
let app_state_for_punch = app_state.clone();
let punch_ctx = PunchTaskContext {
network: app_state.network.clone(),
@@ -129,23 +151,23 @@ pub async fn ping_all(
ping.set_dest_id(id.into());
ping.set_payload(&crate::utils::time::now_ts_ms().to_be_bytes())
.unwrap();
if socket_manager
.send_to(ping, &route.route_key())
.await
.is_ok()
{
packet_loss_stats.record_sent(id);
let route_key = route.route_key();
if socket_manager.send_to(ping, &route_key).await.is_ok() {
packet_loss_stats.record_sent(id, route_key);
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
pub async fn route_timeout_task(route_table: RouteTable) {
pub async fn route_timeout_task(route_table: RouteTable, packet_loss_stats: PacketLossStats) {
loop {
tokio::time::sleep(Duration::from_secs(10)).await;
let expired_time = std::time::Instant::now() - Duration::from_secs(10);
route_table.remove_oldest_route(expired_time);
let removed_keys = route_table.remove_oldest_route(expired_time);
if !removed_keys.is_empty() {
packet_loss_stats.remove_batch(&removed_keys);
}
}
}
@@ -202,22 +224,19 @@ pub async fn relay_probe_task(
if non_direct_targets.len() <= 10 {
non_direct_targets
} else {
non_direct_targets
.choose_multiple(&mut rng, 10)
.copied()
.collect()
non_direct_targets.sample(&mut rng, 10).copied().collect()
}
};
let all_routes = route_table.route_table();
let mut direct_peers = Vec::new();
for (ip, routes) in &all_routes {
if let Some(best_route) = routes.first() {
if best_route.is_direct() {
if let Some(best_route) = routes.first()
&& best_route.is_direct()
{
direct_peers.push((*ip, best_route.route_key()));
}
}
}
if direct_peers.is_empty() {
log::debug!("No direct peers available for relay probe");
@@ -232,30 +251,28 @@ pub async fn relay_probe_task(
direct_peers
.iter()
.filter(|(ip, _)| ip != target_ip)
.choose_multiple(&mut rng, max_probes_per_target)
.sample(&mut rng, max_probes_per_target)
.into_iter()
.cloned()
.collect()
};
for (relay_ip, route_key) in selected_peers {
// 构造Ping消息,目标是target_ip,但发送给relay_ip
let Ok(mut ping) = NetPacket::new(TransmissionBytes::zeroed_size(
HEAD_LENGTH + 8,
// 构造RelayProbe消息,目标是target_ip,但发送给relay_ip
let Ok(mut probe) = NetPacket::new(TransmissionBytes::zeroed_size(
HEAD_LENGTH,
socket_manager.encrypt_reserve(),
)) else {
continue;
};
ping.set_msg_type(MsgType::Ping);
ping.set_ttl(2); // TTL设为2,允许中继一次
ping.set_src_id(src.into());
ping.set_dest_id((*target_ip).into());
ping.set_payload(&crate::utils::time::now_ts_ms().to_be_bytes())
.unwrap();
probe.set_msg_type(MsgType::RelayProbe);
probe.set_ttl(2); // TTL设为2,允许中继一次
probe.set_src_id(src.into());
probe.set_dest_id((*target_ip).into());
// 发送给已打洞的客户端,让它中继到目标
if let Err(e) = socket_manager.send_to(ping, &route_key).await {
if let Err(e) = socket_manager.send_to(probe, &route_key).await {
log::debug!(
"Failed to send relay probe to {} for target {}: {:?}",
relay_ip,
@@ -6,7 +6,7 @@ use crate::crypto::PacketCrypto;
use crate::enhanced_tunnel::inbound::EnhancedInbound;
use crate::fec::FecDecoder;
use crate::protocol::control_message::{
ConfirmRegResponseMsg, RegResponseMsg, RegistrationMode, RequestMessage, ResponseMessage,
ConfirmRegResponseMsg, RegistrationMode, RequestMessage, ResponseMessage,
};
use crate::tunnel_core::p2p::transport::punch::NatPuncher;
use crate::tunnel_core::server::inbound::ServerTurnInboundHandler;
@@ -47,13 +47,14 @@ pub(crate) fn create_server_tunnel(
app_state: AppState,
config: &Config,
packet_crypto: PacketCrypto,
default_interface: Option<rust_p2p_core::socket::LocalInterface>,
) -> (Vec<ServerTurnManager>, ServerOutbound, ServerRPC) {
let mut rpc_notifier: HashMap<u32, RpcNotifier> = HashMap::new();
let mut sender_map: HashMap<u32, Sender<(Bytes, Instant)>> = HashMap::new();
let mut server_manager_list = Vec::with_capacity(config.server_addr.len());
let mut server_addr_list = Vec::with_capacity(config.server_addr.len());
for (index, server_addr) in config.server_addr.iter().enumerate() {
let connect_reg_config = config.to_connect_config(index);
let connect_reg_config = config.to_connect_config(index, default_interface.clone());
let server_id = index as u32;
@@ -117,9 +118,7 @@ impl ServerTurnManager {
let request_msg = RequestMessage::Reg(reg_msg);
let encoded = request_msg.encode();
self.transport_client
.send(encoded.freeze())
.await?;
self.transport_client.send(encoded.freeze()).await?;
let buf = self
.transport_client
.next_timeout(Duration::from_secs(10))
@@ -164,9 +163,7 @@ impl ServerTurnManager {
config: Box<InboundHandlerConfig>,
initial_response: NetworkAddr,
) {
let data_handler =
ServerTurnInboundHandler::new(self.server_id, initial_response, config);
let task_group_ = task_group.clone();
let data_handler = ServerTurnInboundHandler::new(self.server_id, initial_response, config);
let Some(mut receiver) = self.receiver.take() else {
unreachable!()
};
@@ -191,7 +188,12 @@ impl ServerTurnManager {
|| reg.prefix_len != initial_response.prefix_len
|| reg.gateway != initial_response.gateway
{
log::error!("虚拟网络发生变化");
// 该服务器分配的虚拟网络与当前不一致,无法重连,
// 只结束本服务器的任务,不影响其他服务器
log::error!(
"服务器{}虚拟网络发生变化,放弃重连",
self.config.server_addr
);
break;
}
// 保存服务器版本
@@ -200,22 +202,23 @@ impl ServerTurnManager {
}
}
ResponseMessage::Error(e) => {
log::error!("注册失败 {e:?}");
break;
// 单台服务器注册失败只影响本服务器的重连,
// 退避后重试,不能拖垮整个任务组
log::error!("注册失败 {e:?}5秒后重试");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
_ => {
log::error!("错误的注册消息");
break;
log::error!("错误的注册消息5秒后重试");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
}
}
log::info!("已连接服务器:{}", self.config.server_addr);
data_handler.handle_connected();
if let Err(e) = self
.data_handle_loop(&mut receiver, &data_handler)
.await
{
if let Err(e) = self.data_handle_loop(&mut receiver, &data_handler).await {
log::error!("Error on data_handle_loop: {:?}", e);
}
already_connected = false;
@@ -223,7 +226,6 @@ impl ServerTurnManager {
}
self.disconnect();
data_handler.handle_disconnected();
task_group_.stop();
});
}
@@ -270,8 +272,8 @@ impl ServerTurnManager {
/// 3. Send confirmation to all servers
/// 4. Return the registration response
pub async fn coordinated_registration(
managers: &mut Vec<ServerTurnManager>,
) -> anyhow::Result<RegResponseMsg> {
managers: &mut [ServerTurnManager],
) -> anyhow::Result<ResponseMessage> {
if managers.is_empty() {
bail!("No servers to register");
}
@@ -287,7 +289,10 @@ pub async fn coordinated_registration(
let ip = match &first_response {
ResponseMessage::Reg(reg) => reg.ip,
ResponseMessage::Error(e) => bail!("First server registration failed: {}", e.message),
ResponseMessage::Error(e) => {
log::info!("First server registration failed: {}", e.message);
return Ok(first_response);
}
_ => bail!("Unexpected response from first server"),
};
log::info!("Got IP {} from first server", ip);
@@ -313,7 +318,8 @@ pub async fn coordinated_registration(
log::info!("Server {} pre-registered successfully", i + 1);
}
Ok(ResponseMessage::Error(e)) => {
bail!("Server {} registration failed: {}", i + 1, e.message)
log::info!("Server {} registration failed: {}", i + 1, e.message);
return Ok(ResponseMessage::Error(e.clone()));
}
Err(e) => bail!("Server {} registration failed: {}", i + 1, e),
_ => bail!("Unexpected response from server {}", i + 1),
@@ -339,8 +345,5 @@ pub async fn coordinated_registration(
log::info!("Coordinated registration completed successfully");
// Return first server's response (contains IP info)
match first_response {
ResponseMessage::Reg(reg) => Ok(reg),
_ => unreachable!(),
}
Ok(first_response)
}
+2 -2
View File
@@ -76,7 +76,7 @@ impl ServerTurnInboundHandler {
}
pub async fn handle_server_data(
& self,
&self,
transport_client: &mut TransportClient,
network_addr: NetworkAddr,
data: TransmissionBytes,
@@ -254,7 +254,7 @@ impl ServerTurnInboundHandler {
net_packet.set_payload(&bytes_mut)?;
self.packet_crypto.encrypt_in_place(&mut net_packet)?;
transport_client.send_turn(net_packet).await?;
}else{
} else {
log::info!("限制打洞频率")
}
}
@@ -2,6 +2,7 @@ use crate::protocol::control_message::{RegRequestMsg, RegistrationMode};
use crate::tls::verifier::CertValidationMode;
use anyhow::Context;
use rand::seq::SliceRandom;
use rust_p2p_core::socket::LocalInterface;
use std::fmt;
use std::net::{Ipv4Addr, SocketAddr};
use std::str::FromStr;
@@ -16,6 +17,7 @@ pub(crate) struct ConnectRegConfig {
pub ip: Option<Ipv4Addr>,
pub key_sign: Option<String>,
pub ip_variable: bool,
pub default_interface: Option<LocalInterface>,
}
#[derive(Debug, Clone)]
pub(crate) struct ConnectConfig {
@@ -23,6 +25,7 @@ pub(crate) struct ConnectConfig {
pub server_addr: SocketAddr,
pub server_domain: String,
pub cert_mode: CertValidationMode,
pub default_interface: Option<LocalInterface>,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
pub enum ProtocolType {
@@ -110,40 +113,52 @@ impl ConnectRegConfig {
let mut txt = crate::utils::dns_query::dns_query_txt(
&self.server_addr.address,
vec![],
&None,
&self.default_interface,
)
.await?;
txt.shuffle(&mut rand::rng());
let x = txt.first().context("DNS query failed")?;
let x = x.to_lowercase();
let (protocol_type, domain) = if let Some(v) = x.strip_prefix("udp://") {
(ProtocolType::Quic, v)
} else if let Some(v) = x.strip_prefix("quic://") {
(ProtocolType::Quic, v)
} else if let Some(v) = x.strip_prefix("tcp://") {
(ProtocolType::TlsTcp, v)
} else if let Some(v) = x.strip_prefix("ws://") {
(ProtocolType::TlsTcp, v)
} else if let Some(v) = x.strip_prefix("wss://") {
(ProtocolType::TlsTcp, v)
} else {
(ProtocolType::TlsTcp, x.as_str())
};
let (protocol_type, domain) = parse_dynamic_txt(&x);
(protocol_type, domain.to_owned())
}
v => (v, self.server_addr.address.to_string()),
};
let server_addr =
crate::utils::dns_query::dns_query_one(&server_domain, &vec![], &None).await?;
let server_addr = crate::utils::dns_query::dns_query_one(
&server_domain,
&vec![],
&self.default_interface,
)
.await?;
let server_domain = strip_port(&server_domain).to_owned();
Ok(ConnectConfig {
protocol_type,
server_addr,
server_domain,
cert_mode: self.cert_mode.clone(),
default_interface: self.default_interface.clone(),
})
}
}
/// 解析动态 DNS TXT 记录中的协议前缀。
/// 注意 wss:// 必须映射到 Wss(此前错映射为 TlsTcp,导致动态发现模式下
/// WSS 实际不可用)。
fn parse_dynamic_txt(txt: &str) -> (ProtocolType, &str) {
if let Some(v) = txt.strip_prefix("udp://") {
(ProtocolType::Quic, v)
} else if let Some(v) = txt.strip_prefix("quic://") {
(ProtocolType::Quic, v)
} else if let Some(v) = txt.strip_prefix("tcp://") {
(ProtocolType::TlsTcp, v)
} else if let Some(v) = txt.strip_prefix("ws://") {
(ProtocolType::TlsTcp, v)
} else if let Some(v) = txt.strip_prefix("wss://") {
(ProtocolType::Wss, v)
} else {
(ProtocolType::TlsTcp, txt)
}
}
fn strip_port(addr: &str) -> &str {
if let Some(stripped) = addr.strip_prefix('[')
&& let Some(pos) = stripped.find(']')
@@ -171,3 +186,38 @@ impl ConnectConfig {
&self.server_domain
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 动态 DNS TXT 记录中的 wss:// 必须映射为 Wss
/// 其余前缀维持原有映射不变
#[test]
fn test_parse_dynamic_txt() {
assert_eq!(
parse_dynamic_txt("wss://example.com:443"),
(ProtocolType::Wss, "example.com:443")
);
assert_eq!(
parse_dynamic_txt("quic://example.com:29872"),
(ProtocolType::Quic, "example.com:29872")
);
assert_eq!(
parse_dynamic_txt("udp://example.com:29872"),
(ProtocolType::Quic, "example.com:29872")
);
assert_eq!(
parse_dynamic_txt("tcp://example.com:29872"),
(ProtocolType::TlsTcp, "example.com:29872")
);
assert_eq!(
parse_dynamic_txt("ws://example.com:80"),
(ProtocolType::TlsTcp, "example.com:80")
);
assert_eq!(
parse_dynamic_txt("example.com:29872"),
(ProtocolType::TlsTcp, "example.com:29872")
);
}
}
@@ -4,7 +4,7 @@ use crate::tunnel_core::server::transport::config::ConnectConfig;
use anyhow::{Context, bail};
use bytes::Bytes;
use futures::{SinkExt, StreamExt};
use quinn::{ClientConfig, Endpoint, RecvStream, SendStream};
use quinn::{ClientConfig, Endpoint, RecvStream, SendStream, TokioRuntime};
use std::sync::Arc;
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
@@ -56,14 +56,15 @@ pub async fn connect_quic(
let server_addr = config.server_addr();
let server_name = config.server_name();
let quic_config = create_client_config(&config.cert_mode)?;
let mut endpoint = match Endpoint::client((std::net::Ipv6Addr::UNSPECIFIED, 0).into()) {
Ok(endpoint) => endpoint,
Err(e) => {
log::warn!("Failed to create QUIC endpoint: {}", e);
Endpoint::client((std::net::Ipv4Addr::UNSPECIFIED, 0).into())
.context("Failed to create QUIC endpoint")?
}
let bind_addr = if server_addr.is_ipv4() {
(std::net::Ipv4Addr::UNSPECIFIED, 0).into()
} else {
(std::net::Ipv6Addr::UNSPECIFIED, 0).into()
};
let socket = crate::utils::socket::bind_udp(bind_addr, config.default_interface.as_ref())?;
let socket = socket.into_std()?;
let mut endpoint = Endpoint::new(Default::default(), None, socket, Arc::new(TokioRuntime))
.context("Failed to create QUIC endpoint")?;
endpoint.set_default_client_config(quic_config);
let connection = endpoint
@@ -58,7 +58,8 @@ pub async fn connect_tls_tcp(
let rustls_config = config.cert_mode.create_tls_client_config()?;
let connector = TlsConnector::from(Arc::new(rustls_config));
let tcp_stream = TcpStream::connect(server_addr)
let tcp_stream =
crate::utils::socket::connect_tcp(server_addr, config.default_interface.as_ref())
.await
.context("Failed to establish underlying TCP connection")?;
if let Err(e) = tcp_stream.set_nodelay(true) {
@@ -71,7 +71,8 @@ pub async fn connect_wss(config: &ConnectConfig) -> anyhow::Result<WssStream> {
let rustls_config = config.cert_mode.create_tls_client_config()?;
let connector = TlsConnector::from(Arc::new(rustls_config));
let tcp_stream = TcpStream::connect(server_addr)
let tcp_stream =
crate::utils::socket::connect_tcp(server_addr, config.default_interface.as_ref())
.await
.context("Failed to establish underlying TCP connection")?;
if let Err(e) = tcp_stream.set_nodelay(true) {
+30 -5
View File
@@ -1,7 +1,8 @@
use anyhow::Context;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
pub fn get_device_id() -> anyhow::Result<String> {
#[cfg(not(target_os = "android"))]
match machine_uid::get() {
Ok(id) => return Ok(id),
Err(e) => {
@@ -11,10 +12,21 @@ pub fn get_device_id() -> anyhow::Result<String> {
get_fallback_id()
}
fn get_fallback_id() -> anyhow::Result<String> {
let path = Path::new("device_id");
if let Ok(content) = fs::read_to_string(path) {
/// fallback 设备 ID 文件路径:锚定可执行文件所在目录。
/// 相对路径会随进程 CWD 变化,换目录启动就会读写另一个文件,
/// 导致设备 ID 漂移
fn fallback_id_path() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|dir| dir.join("device_id")))
.unwrap_or_else(|| PathBuf::from("device_id"))
}
fn get_fallback_id() -> anyhow::Result<String> {
let path = fallback_id_path();
if let Ok(content) = fs::read_to_string(&path) {
let id = content.trim();
if !id.is_empty() {
return Ok(id.to_string());
@@ -23,7 +35,20 @@ fn get_fallback_id() -> anyhow::Result<String> {
let new_id = uuid::Uuid::new_v4().to_string();
fs::write(path, &new_id).context("Failed to write device_id file")?;
fs::write(&path, &new_id).context("Failed to write device_id file")?;
Ok(new_id)
}
#[cfg(test)]
mod tests {
use super::*;
/// 路径必须锚定到可执行文件目录(绝对路径),不能是随 CWD 漂移的相对路径
#[test]
fn test_fallback_id_path_is_absolute() {
let path = fallback_id_path();
assert!(path.is_absolute(), "path should be absolute: {path:?}");
assert_eq!(path.file_name().unwrap(), "device_id");
}
}
+65 -1
View File
@@ -44,6 +44,10 @@ pub async fn dns_query_one(
default_interface: &Option<LocalInterface>,
) -> anyhow::Result<SocketAddr> {
let mut vec = dns_query_all(domain, name_servers, default_interface).await?;
if default_interface.is_some() {
// 出口网卡绑定目前应用于 IPv4 Socket;优先且只使用可绑定的 IPv4 地址。
vec.retain(SocketAddr::is_ipv4);
}
vec.shuffle(&mut rand::rng());
vec.pop().context("DNS query failed")
}
@@ -121,6 +125,18 @@ pub async fn dns_query_all(
}
}
/// 校验域名格式:label 非空且不超过 63 字节,全长不超过 253 字节。
/// dns-parser 的 add_question 对非法 label 直接 assert panic
/// 必须在调用前拦截
fn is_valid_domain(domain: &str) -> bool {
let domain = domain.strip_suffix('.').unwrap_or(domain);
!domain.is_empty()
&& domain.len() <= 253
&& domain
.split('.')
.all(|label| !label.is_empty() && label.len() <= 63)
}
async fn query<'a>(
udp: &UdpSocket,
domain: &str,
@@ -128,9 +144,21 @@ async fn query<'a>(
record_type: QueryType,
buf: &'a mut [u8],
) -> io::Result<Packet<'a>> {
if !is_valid_domain(domain) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid domain {domain:?}"),
));
}
let mut builder = Builder::new_query(1, true);
builder.add_question(domain, false, record_type, QueryClass::IN);
let packet = builder.build().unwrap();
// 非法域名(如 label 超长)build 会失败,不能 unwrap panic
let packet = builder.build().map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid domain {domain:?}: {e:?}"),
)
})?;
udp.connect(name_server).await?;
let mut count = 0;
@@ -249,3 +277,39 @@ pub async fn aaaa_dns(
}
Ok(rs)
}
#[cfg(test)]
mod tests {
use super::*;
/// 非法域名(label 超过 63 字节)必须返回错误而不是 panic
#[tokio::test]
async fn test_query_invalid_domain_no_panic() {
let udp = UdpSocket::bind("0.0.0.0:0").await.unwrap();
let mut buf = vec![0u8; 512];
let bad_domain = format!("{}.com", "a".repeat(64));
let rs = query(
&udp,
&bad_domain,
"127.0.0.1:53".parse().unwrap(),
QueryType::A,
&mut buf,
)
.await;
let err = rs.expect_err("invalid domain must be rejected");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn test_is_valid_domain() {
assert!(is_valid_domain("example.com"));
assert!(is_valid_domain("a-b_1.example.com"));
assert!(is_valid_domain("example.com.")); // FQDN 尾点合法
assert!(is_valid_domain(&format!("{}.com", "a".repeat(63))));
assert!(!is_valid_domain(""));
assert!(!is_valid_domain(&format!("{}.com", "a".repeat(64))));
assert!(!is_valid_domain("a..b"));
assert!(!is_valid_domain(&"a".repeat(254)));
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod device_id;
pub(crate) mod dns_query;
pub(crate) mod socket;
pub mod task_control;
pub(crate) mod time {
pub fn now_ts_ms() -> i64 {
+216
View File
@@ -0,0 +1,216 @@
use anyhow::Context;
use rust_p2p_core::socket::LocalInterface;
use socket2::{Domain, Protocol, Socket, Type};
use std::io;
use std::net::SocketAddr;
#[derive(Clone, Debug)]
pub(crate) struct ResolvedInterface {
pub(crate) name: String,
pub(crate) socket_interface: LocalInterface,
}
/// 将配置中的网卡名称解析为底层 Socket 所需的接口标识。
/// Linux/Android 使用名称绑定,Windows/macOS 使用接口索引。
pub(crate) fn resolve_interface(name: Option<&str>) -> anyhow::Result<Option<ResolvedInterface>> {
let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
return Ok(None);
};
let (canonical_name, index) = match getifaddrs::if_nametoindex(name) {
Ok(index) => (
getifaddrs::if_indextoname(index).unwrap_or_else(|_| name.to_owned()),
index,
),
Err(_) => {
let interface = getifaddrs::getifaddrs()
.context("读取本机网卡列表失败")?
.find(|interface| {
if interface.name.eq_ignore_ascii_case(name) {
return true;
}
#[cfg(windows)]
{
interface.description.eq_ignore_ascii_case(name)
}
#[cfg(not(windows))]
{
false
}
})
.ok_or_else(|| anyhow::anyhow!("找不到出口网卡 '{name}'"))?;
let index = interface
.index
.ok_or_else(|| anyhow::anyhow!("出口网卡 '{}' 没有可用索引", interface.name))?;
(interface.name, index)
}
};
#[cfg(any(target_os = "linux", target_os = "android"))]
let interface = LocalInterface::new(canonical_name.clone());
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let interface = LocalInterface::new(index);
// Linux/Android 只用名称,但仍执行 if_nametoindex 来提前校验配置。
let _ = index;
Ok(Some(ResolvedInterface {
name: canonical_name,
socket_interface: interface,
}))
}
pub(crate) trait SocketTrait {
fn set_ip_unicast_if(&self, _interface: &LocalInterface) -> io::Result<()> {
Ok(())
}
}
#[cfg(target_os = "windows")]
impl SocketTrait for Socket {
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
use std::os::windows::io::AsRawSocket;
use windows_sys::Win32::Networking::WinSock::{
IP_UNICAST_IF, IPPROTO_IP, SOCKET_ERROR, htonl, setsockopt,
};
let raw_socket = self.as_raw_socket();
let result = unsafe {
let best_interface = htonl(interface.index);
setsockopt(
raw_socket as usize,
IPPROTO_IP,
IP_UNICAST_IF,
&best_interface as *const _ as *const u8,
std::mem::size_of_val(&best_interface) as i32,
)
};
if result == SOCKET_ERROR {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
impl SocketTrait for Socket {
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
self.bind_device(Some(interface.name.as_bytes()))
}
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
impl SocketTrait for Socket {
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
self.bind_device_by_index_v4(std::num::NonZeroU32::new(interface.index))
}
}
#[cfg(target_os = "freebsd")]
impl SocketTrait for Socket {}
pub(crate) fn bind_socket_to_interface(
socket: &Socket,
interface: Option<&LocalInterface>,
is_ipv4: bool,
) -> io::Result<()> {
if is_ipv4 && let Some(interface) = interface {
socket.set_ip_unicast_if(interface)?;
}
Ok(())
}
pub(crate) fn bind_udp(
addr: SocketAddr,
interface: Option<&LocalInterface>,
) -> io::Result<tokio::net::UdpSocket> {
let socket = rust_p2p_core::socket::bind_udp(addr, interface)?;
tokio::net::UdpSocket::from_std(socket.into())
}
pub(crate) async fn connect_tcp(
addr: SocketAddr,
interface: Option<&LocalInterface>,
) -> io::Result<tokio::net::TcpStream> {
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
bind_socket_to_interface(
&socket,
interface,
addr.is_ipv4() && !addr.ip().is_loopback(),
)?;
socket.set_nonblocking(true)?;
socket.set_tcp_nodelay(true)?;
match socket.connect(&addr.into()) {
Ok(()) => {}
Err(ref error) if error.kind() == io::ErrorKind::WouldBlock => {}
#[cfg(unix)]
Err(ref error) if error.raw_os_error() == Some(libc::EINPROGRESS) => {}
Err(error) => return Err(error),
}
let stream = tokio::net::TcpStream::from_std(socket.into())?;
stream.writable().await?;
if let Some(error) = stream.take_error()? {
return Err(error);
}
Ok(stream)
}
pub(crate) async fn connect_tcp_resolved<A: tokio::net::ToSocketAddrs>(
addr: A,
interface: Option<&LocalInterface>,
) -> io::Result<tokio::net::TcpStream> {
let addrs = tokio::net::lookup_host(addr).await?.collect::<Vec<_>>();
let mut last_error = None;
for addr in addrs {
match connect_tcp(addr, interface).await {
Ok(stream) => return Ok(stream),
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| io::Error::other("目标地址解析结果为空")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_interface_name_disables_binding() {
assert!(resolve_interface(None).unwrap().is_none());
assert!(resolve_interface(Some(" ")).unwrap().is_none());
}
#[test]
fn invalid_interface_name_is_rejected() {
let name = "vnt-interface-that-must-not-exist";
assert!(resolve_interface(Some(name)).is_err());
}
#[test]
fn existing_interface_name_resolves() {
let interface = getifaddrs::getifaddrs()
.unwrap()
.find(|interface| interface.index.is_some())
.expect("at least one indexed interface");
let resolved = resolve_interface(Some(&interface.name)).unwrap().unwrap();
assert_eq!(resolved.name, interface.name);
#[cfg(not(any(target_os = "linux", target_os = "android")))]
assert_eq!(resolved.socket_interface.index, interface.index.unwrap());
}
#[cfg(windows)]
#[test]
fn windows_friendly_name_resolves() {
let interface = getifaddrs::getifaddrs()
.unwrap()
.find(|interface| interface.index.is_some() && !interface.description.is_empty())
.expect("at least one described interface");
let resolved = resolve_interface(Some(&interface.description))
.unwrap()
.unwrap();
assert_eq!(resolved.socket_interface.index, interface.index.unwrap());
}
}
+81 -8
View File
@@ -36,12 +36,16 @@ impl TaskGroupInner {
return None;
}
let guard = TaskGuard {
inner: Arc::downgrade(self),
};
let weak = Arc::downgrade(self);
let handle = tokio::spawn(async move {
let _guard = guard;
// 在任务上下文内获取自身 id 存入 guard;
// 不能延迟到 Drop 里调 tokio::task::id()
// abort 路径下 future 可能在非任务上下文被销毁(panic),
// 在调用方任务上下文被销毁时又会拿到错误的 id 误删条目
let _guard = TaskGuard {
inner: weak,
task_id: tokio::task::id(),
};
f.await;
});
@@ -107,13 +111,14 @@ impl Drop for TaskGroupInner {
struct TaskGuard {
inner: Weak<TaskGroupInner>,
/// 创建时(任务上下文内)获取的自身任务 id
task_id: Id,
}
impl Drop for TaskGuard {
fn drop(&mut self) {
if let Some(inner) = self.inner.upgrade() {
let task_id = tokio::task::id();
inner.remove_task(task_id);
inner.remove_task(self.task_id);
}
}
}
@@ -155,10 +160,14 @@ impl TaskGroup {
pub async fn wait_all_stopped(&self) {
loop {
// 先注册等待再检查条件,避免在检查与等待之间丢失唤醒
let notified = self.inner.all_stopped_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if self.inner.all_tasks_stopped() {
return;
}
self.inner.all_stopped_notify.notified().await;
notified.await;
}
}
}
@@ -253,3 +262,67 @@ impl Drop for TaskGroupGuard {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 所有任务自然结束后 wait_all_stopped 必须返回。
/// 覆盖两个关键点:任务自然耗尽时 remove_task 置 stopped 并唤醒;
/// 等待方先注册再检查,不会因竞态错过唤醒而永久挂起。
#[tokio::test]
async fn test_wait_all_stopped_after_natural_completion() {
let manager = TaskGroupManager::new();
let (group, _guard) = manager.create_task().unwrap();
let waiter = {
let group = group.clone();
tokio::spawn(async move { group.wait_all_stopped().await })
};
// 让 waiter 先进入等待
tokio::task::yield_now().await;
let _sub = group.spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
});
tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.expect("wait_all_stopped should return after all tasks complete")
.unwrap();
}
/// abort 路径:任务被 stop() 终止后,TaskGuard 必须用创建时保存的 id
/// 注销自身;若在 Drop 里调 tokio::task::id(),在非任务上下文会 panic
/// 在调用方任务上下文则会误删调用方的条目。
#[tokio::test]
async fn test_abort_task_keeps_caller_bookkeeping() {
let manager = TaskGroupManager::new();
let (group, _guard) = manager.create_task().unwrap();
let victim = group.spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
});
// observer 在同组任务内 abort victim,随后挂起等待放行
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<()>();
let observer = group.spawn(async move {
victim.stop().await;
let _ = done_tx.send(());
let _ = exit_rx.await;
});
done_rx.await.unwrap();
// victim 的 guard 注销不得误删 observer 的条目
assert!(
observer.is_running(),
"aborting victim must not remove the caller's task entry"
);
let _ = exit_tx.send(());
tokio::time::timeout(std::time::Duration::from_secs(2), group.wait_all_stopped())
.await
.expect("wait_all_stopped should return after observer exits");
}
}
+74
View File
@@ -0,0 +1,74 @@
# VNT Desktop
基于 Tauri 2 + Vue 3 的 VNT PC 客户端。桌面端直接内置 `vnt-web` 的服务能力,不需要另行启动 `vnt2_web`
桌面端与 Web 端共用 `vnt-web/ui/src/` 下的同一套 Vue 应用、路由、状态、组件和样式。`vnt-desktop/src/main.js` 只负责注册 Tauri IPC 桥接,不维护第二套界面代码。
## 功能
- 桌面工作台:总览、实例、在线设备、路由和配置管理
- 多实例启动、停止、重启及启动日志
- 表单 / TOML 双模式配置编辑
- 原生窗口、系统托盘、单实例运行
- 关闭主窗口时隐藏到托盘,托盘菜单可彻底退出
- 深浅主题与 `Ctrl/Cmd + 1~5` 页面快捷键
- 响应式布局:桌面侧栏、移动端顶部栏和抽屉导航
- 桌面工作台通过 Tauri IPC 直接调用进程内 `vnt-core`,不监听本地 API 端口
- 可选 Web 访问:启停、端口、本机/局域网监听范围、访问令牌、打开浏览器
- Web API 强制使用 Bearer 令牌鉴权,令牌可在桌面端重新生成
- 关于页提供 GitHub 开源地址与更新检查;桌面端可通过 Tauri Updater 下载并安装更新
桌面数据存放在系统应用数据目录的 `com.vnt.desktop` 下,包括 `vnt_config`、自启动记录、`web_access.toml`、日志及 Windows 下的 `wintun.dll`
## 开发
要求:Rust、Node.js、pnpm,以及 Tauri 2 对应的系统依赖。
依赖由仓库根目录的 pnpm workspace 统一管理:
```powershell
pnpm install
pnpm dev:desktop
```
只验证前端:
```powershell
pnpm build:desktop-ui
```
验证 Rust 桌面模块:
```powershell
cargo check -p vnt-desktop
```
## 打包
```powershell
$env:TAURI_SIGNING_PRIVATE_KEY=Get-Content -Raw -LiteralPath "$HOME\.tauri\vnt-desktop.key"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
pnpm build:desktop
```
Windows 使用虚拟网卡模式时,可能需要以管理员身份运行;TAP(二层)模式还需要预先安装 `tap-windows``tap0901`)驱动,内置的 `wintun.dll` 只用于 TUN(三层)模式。
### 发布桌面更新
Tauri Updater 会从以下 GitHub Release 资源检查更新:
```text
https://github.com/vnt-dev/vnt/releases/latest/download/latest.json
```
更新包必须使用与 `tauri.conf.json` 内公钥匹配的私钥签名。当前密钥默认保存在 `~/.tauri/vnt-desktop.key`,请妥善备份并在 CI 中将私钥内容配置为 `TAURI_SIGNING_PRIVATE_KEY`;私钥不得提交到仓库。发布时需将 Tauri 生成的安装包、`.sig` 文件以及对应的 `latest.json` 一并上传到 GitHub Release。
仓库的 Release 流水线会在推送版本标签后自动构建 Windows 桌面安装包及 updater 元数据。请在 GitHub Actions Secrets 中配置 `TAURI_SIGNING_PRIVATE_KEY`;如果私钥设置了密码,同时配置 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`。流水线还会将桌面安装包和各平台 VNT 工具包的直接下载链接写入 Release Notes。
## 应用图标
图标母版保存在 `vnt-desktop/assets/vnt-icon-master.png`。需要重新生成各平台图标时,在仓库根目录执行:
```powershell
pnpm --filter vnt-desktop tauri icon assets/vnt-icon-master.png --ios-color '#4F46E5'
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#07111f" />
<title>VNT Desktop</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "vnt-desktop",
"private": true,
"version": "2.0.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@tauri-apps/cli": "^2.8.4",
"@vitejs/plugin-vue": "catalog:",
"tailwindcss": "catalog:",
"vite": "catalog:"
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "vnt-desktop"
version = "2.0.2"
description = "VNT virtual network desktop client"
edition = "2024"
[lib]
name = "vnt_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
tauri = { version = "2.11.5", features = ["tray-icon"] }
tauri-plugin-single-instance = "2.4.3"
tauri-plugin-opener = "2"
vnt-web = { path = "../../vnt-web" }
vnt2 = { path = "../.." }
log.workspace = true
tokio = { workspace = true, features = ["full"] }
tokio-util.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
anyhow.workspace = true
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"description": "VNT Desktop main window permissions",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"opener:allow-open-url",
"process:allow-restart",
"updater:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+324
View File
@@ -0,0 +1,324 @@
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Manager, WindowEvent};
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
use vnt_web::VntService;
#[cfg(windows)]
mod wintun;
static EXITING: AtomicBool = AtomicBool::new(false);
const WEB_ACCESS_CONFIG: &str = "web_access.toml";
#[derive(Clone, Deserialize, Serialize)]
#[serde(default)]
struct WebAccessConfig {
enabled: bool,
port: u16,
global: bool,
token: String,
}
impl Default for WebAccessConfig {
fn default() -> Self {
Self {
enabled: false,
port: 19099,
global: false,
token: vnt_web::generate_access_token(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WebAccessStatus {
enabled: bool,
running: bool,
port: u16,
global: bool,
token: String,
url: String,
listen_address: String,
}
struct WebRuntime {
config: WebAccessConfig,
cancellation: Option<CancellationToken>,
handle: Option<tokio::task::JoinHandle<anyhow::Result<()>>>,
}
struct DesktopState {
service: VntService,
web: tokio::sync::Mutex<WebRuntime>,
config_path: PathBuf,
}
fn load_web_config(path: &Path) -> WebAccessConfig {
let mut config: WebAccessConfig = std::fs::read_to_string(path)
.ok()
.and_then(|text| toml::from_str(&text).ok())
.unwrap_or_default();
if config.port == 0 {
config.port = 19099;
}
if config.token.len() < 16 {
config.token = vnt_web::generate_access_token();
}
config
}
fn save_web_config(path: &Path, config: &WebAccessConfig) -> anyhow::Result<()> {
std::fs::write(path, toml::to_string_pretty(config)?)?;
Ok(())
}
fn listen_addr(config: &WebAccessConfig) -> SocketAddr {
let ip = if config.global {
IpAddr::V4(Ipv4Addr::UNSPECIFIED)
} else {
IpAddr::V4(Ipv4Addr::LOCALHOST)
};
SocketAddr::new(ip, config.port)
}
fn lan_ip() -> Option<IpAddr> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?;
socket.connect((Ipv4Addr::new(8, 8, 8, 8), 80)).ok()?;
Some(socket.local_addr().ok()?.ip())
}
fn access_url(config: &WebAccessConfig) -> String {
let host = if config.global {
lan_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
} else {
IpAddr::V4(Ipv4Addr::LOCALHOST)
};
format!("http://{}:{}/?token={}", host, config.port, config.token)
}
async fn stop_web(runtime: &mut WebRuntime) {
if let Some(cancellation) = runtime.cancellation.take() {
cancellation.cancel();
}
if let Some(handle) = runtime.handle.take() {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(error)) => log::warn!("Web service stopped with error: {error:#}"),
Err(error) if !error.is_cancelled() => log::warn!("Web service task failed: {error}"),
Err(_) => {}
}
}
}
async fn start_web(service: &VntService, runtime: &mut WebRuntime) -> anyhow::Result<()> {
if !runtime.config.enabled {
return Ok(());
}
let cancellation = CancellationToken::new();
let handle = service
.start_http(
listen_addr(&runtime.config),
runtime.config.token.clone(),
cancellation.clone(),
)
.await?;
runtime.cancellation = Some(cancellation);
runtime.handle = Some(handle);
Ok(())
}
#[tauri::command]
async fn api_request(
state: tauri::State<'_, DesktopState>,
method: String,
path: String,
body: Option<String>,
) -> Result<serde_json::Value, String> {
if !path.starts_with("/api/") {
return Err("只允许调用 VNT API".to_string());
}
state
.service
.request(&method, &path, body)
.await
.map_err(|error| format!("{error:#}"))
}
fn web_status(runtime: &WebRuntime) -> WebAccessStatus {
WebAccessStatus {
enabled: runtime.config.enabled,
running: runtime
.handle
.as_ref()
.is_some_and(|handle| !handle.is_finished()),
port: runtime.config.port,
global: runtime.config.global,
token: runtime.config.token.clone(),
url: access_url(&runtime.config),
listen_address: listen_addr(&runtime.config).to_string(),
}
}
#[tauri::command]
async fn web_access_status(
state: tauri::State<'_, DesktopState>,
) -> Result<WebAccessStatus, String> {
let runtime = state.web.lock().await;
Ok(web_status(&runtime))
}
#[tauri::command]
async fn update_web_access(
state: tauri::State<'_, DesktopState>,
config: WebAccessConfig,
) -> Result<WebAccessStatus, String> {
if config.port == 0 {
return Err("监听端口必须在 1-65535 之间".to_string());
}
if config.token.len() < 16 {
return Err("访问令牌至少需要 16 个字符".to_string());
}
let mut runtime = state.web.lock().await;
stop_web(&mut runtime).await;
let previous = runtime.config.clone();
runtime.config = config;
if let Err(error) = start_web(&state.service, &mut runtime).await {
runtime.config = previous;
if let Err(restore_error) = start_web(&state.service, &mut runtime).await {
log::error!("Failed to restore Web service: {restore_error:#}");
}
return Err(format!("无法启动 Web 服务:{error:#}"));
}
save_web_config(&state.config_path, &runtime.config)
.map_err(|error| format!("保存 Web 访问设置失败:{error:#}"))?;
Ok(web_status(&runtime))
}
#[tauri::command]
fn generate_web_token() -> String {
vnt_web::generate_access_token()
}
#[tauri::command]
fn open_web_url(app: AppHandle, url: String) -> Result<(), String> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("只允许打开 HTTP(S) 地址".to_string());
}
app.opener()
.open_url(url, None::<&str>)
.map_err(|error| error.to_string())
}
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn toggle_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
match window.is_visible() {
Ok(true) => {
let _ = window.hide();
}
_ => show_main_window(app),
}
}
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
api_request,
web_access_status,
update_web_access,
generate_web_token,
open_web_url
])
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main_window(app);
}))
.setup(|app| {
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_dir)?;
std::env::set_current_dir(&data_dir)?;
vnt2::log::log_init("vnt-desktop");
#[cfg(windows)]
wintun::ensure_wintun(&data_dir)?;
let config_path = data_dir.join(WEB_ACCESS_CONFIG);
let config = load_web_config(&config_path);
save_web_config(&config_path, &config)?;
let service = tauri::async_runtime::block_on(VntService::new_desktop(None))?;
let mut web = WebRuntime {
config,
cancellation: None,
handle: None,
};
if let Err(error) = tauri::async_runtime::block_on(start_web(&service, &mut web)) {
log::error!("Failed to restore Web access service: {error:#}");
web.config.enabled = false;
save_web_config(&config_path, &web.config)?;
}
app.manage(DesktopState {
service,
web: tokio::sync::Mutex::new(web),
config_path,
});
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出 VNT", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
let mut tray = TrayIconBuilder::with_id("vnt-tray")
.tooltip("VNT Desktop")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"quit" => {
EXITING.store(true, Ordering::SeqCst);
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
toggle_main_window(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(app)?;
Ok(())
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event
&& !EXITING.load(Ordering::SeqCst)
{
api.prevent_close();
let _ = window.hide();
}
})
.run(tauri::generate_context!())
.expect("error while running VNT Desktop");
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
vnt_desktop_lib::run();
}
+20
View File
@@ -0,0 +1,20 @@
use std::io;
use std::path::Path;
#[cfg(target_arch = "x86_64")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/amd64/wintun.dll");
#[cfg(target_arch = "x86")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/x86/wintun.dll");
#[cfg(target_arch = "aarch64")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/arm64/wintun.dll");
#[cfg(target_arch = "arm")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/arm/wintun.dll");
pub fn ensure_wintun(data_dir: &Path) -> io::Result<()> {
let target = data_dir.join("wintun.dll");
let current = std::fs::read(&target).unwrap_or_default();
if current != WINTUN_DLL {
std::fs::write(target, WINTUN_DLL)?;
}
Ok(())
}
+61
View File
@@ -0,0 +1,61 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "VNT Desktop",
"version": "2.0.2",
"identifier": "com.vnt.desktop",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "VNT Desktop",
"width": 1180,
"height": 760,
"minWidth": 820,
"minHeight": 600,
"center": true,
"decorations": true,
"resizable": true,
"shadow": true
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.github.com"
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Utility",
"shortDescription": "轻量、高效的虚拟局域网桌面客户端",
"longDescription": "VNT Desktop 用于创建和管理安全、快速的虚拟局域网连接。",
"windows": {
"wix": { "language": "zh-CN" },
"nsis": { "displayLanguageSelector": false }
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE3MTQzQjc3NTJGQjBGRTEKUldUaEQvdFNkenNVcHdwd0pRNEhDV1lTdkMzTDZLa1V3WGllMDRoMmxBNDdobi8wclN1dmo3cFoK",
"endpoints": [
"https://github.com/vnt-dev/vnt/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import { invoke } from "@tauri-apps/api/core";
import { relaunch } from "@tauri-apps/plugin-process";
import { check } from "@tauri-apps/plugin-updater";
let pendingUpdate = null;
globalThis.__VNT_DESKTOP__ = true;
globalThis.__VNT_IPC_REQUEST__ = ({ method, path, body }) =>
invoke("api_request", { method, path, body });
globalThis.__VNT_WEB_ACCESS__ = {
status: () => invoke("web_access_status"),
update: (config) => invoke("update_web_access", { config }),
generateToken: () => invoke("generate_web_token"),
openUrl: (url) => invoke("open_web_url", { url }),
};
globalThis.__VNT_UPDATER__ = {
check: async () => {
pendingUpdate = await check();
if (!pendingUpdate) return null;
return {
currentVersion: pendingUpdate.currentVersion,
version: pendingUpdate.version,
date: pendingUpdate.date || "",
body: pendingUpdate.body || "",
};
},
downloadAndInstall: async (onProgress) => {
if (!pendingUpdate) throw new Error("请先检查更新");
let downloaded = 0;
let contentLength = 0;
await pendingUpdate.downloadAndInstall((event) => {
if (event.event === "Started") {
contentLength = event.data.contentLength || 0;
} else if (event.event === "Progress") {
downloaded += event.data.chunkLength;
}
onProgress?.({ event: event.event, downloaded, contentLength });
});
await relaunch();
},
};
// Tauri 与 Web 端共用同一个 Vue 应用,这里只负责平台初始化。
await import("@shared/main.js");
+24
View File
@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
const sharedUi = fileURLToPath(new URL("../vnt-web/ui/src", import.meta.url));
const desktopRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@shared": sharedUi,
},
},
server: {
port: 1420,
strictPort: true,
host: "127.0.0.1",
fs: { allow: [desktopRoot, sharedUi] },
watch: { ignored: ["**/src-tauri/**"] },
},
clearScreen: false,
});
+10 -9
View File
@@ -1,20 +1,21 @@
[package]
name = "vnt-ipc"
version = "2.0.0"
version = "2.0.2"
edition = "2024"
[dependencies]
vnt-core = { path = "../vnt-core" }
anyhow = "1.0.100"
futures = "0.3.31"
prost = "0.14.1"
tokio = "1.48.0"
tokio-util = "0.7.17"
log = "0.4.29"
vnt-core.workspace = true
anyhow.workspace = true
futures.workspace = true
prost.workspace = true
tokio = { workspace = true, features = ["rt", "net", "io-util", "time"] }
tokio-util.workspace = true
log.workspace = true
time.workspace = true
console = "0.16.2"
time = "0.3.44"
cli-table = "0.5.0"
[build-dependencies]
prost-build = "0.14"
protoc-bin-vendored = "3"
+12
View File
@@ -1,5 +1,17 @@
fn main() {
let mut config = prost_build::Config::new();
match protoc_bin_vendored::protoc_bin_path() {
Ok(protoc_path) => {
config.protoc_executable(protoc_path);
}
Err(error) => {
println!(
"cargo:warning=vendored protoc unavailable ({error:?}); falling back to protoc from PATH"
);
}
}
config.protoc_arg("--experimental_allow_proto3_optional");
config
.compile_protos(&["proto/local_ipc.proto"], &["proto"])
+5 -1
View File
@@ -39,7 +39,11 @@ pub async fn run_client(cmd: IpcCmd, port: Option<u16>) -> anyhow::Result<()> {
framed
.send(IpcRequest { ipc_cmd: Some(cmd) }.encode_to_vec().into())
.await?;
let response = framed.next().await.context("Unexpected end of stream")??;
// 读响应加超时:服务端异常不回复时客户端不能永久挂起
let response = tokio::time::timeout(Duration::from_secs(5), framed.next())
.await
.context("Response timed out")?
.context("Unexpected end of stream")??;
let response = IpcResponse::decode(response).context("decode response error")?;
match response
.response_payload
+16 -1
View File
@@ -8,5 +8,20 @@ const DEFAULT_PORT: u16 = 11233;
const PORT_FILE: &str = "PORT";
fn get_port_file_path() -> std::path::PathBuf {
std::path::PathBuf::from(PORT_FILE)
// 锚定可执行文件目录,相对路径会随 CWD 变化导致读不到 PORT 文件
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|dir| dir.join(PORT_FILE)))
.unwrap_or_else(|| std::path::PathBuf::from(PORT_FILE))
}
#[cfg(test)]
mod tests {
/// PORT 文件路径必须锚定到可执行文件目录(绝对路径)
#[test]
fn test_port_file_path_is_absolute() {
let path = super::get_port_file_path();
assert!(path.is_absolute(), "path should be absolute: {path:?}");
assert_eq!(path.file_name().unwrap(), super::PORT_FILE);
}
}
+17 -2
View File
@@ -11,6 +11,7 @@ use futures::{SinkExt, StreamExt};
use prost::Message;
use std::fs;
use std::net::Ipv4Addr;
use std::time::Duration;
use tokio::io::{self};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::codec::{Framed, LengthDelimitedCodec};
@@ -19,7 +20,14 @@ use vnt_core::api::VntApi;
async fn handle_connection(stream: TcpStream, vnt_api: VntApi) -> anyhow::Result<()> {
let mut framed = Framed::new(stream, LengthDelimitedCodec::new());
if let Some(Ok(message)) = framed.next().await {
// 读请求加超时:空闲连接不能永久占用任务
let message = match tokio::time::timeout(Duration::from_secs(10), framed.next()).await {
Ok(Some(Ok(message))) => message,
Ok(Some(Err(e))) => return Err(e.into()),
Ok(None) => bail!("connection closed without request"),
Err(_) => bail!("read request timed out"),
};
{
let request = IpcRequest::decode(message.as_ref())?;
let Some(cmd) = request.ipc_cmd else {
bail!("Received an IpcRequest but it was None");
@@ -178,8 +186,15 @@ pub async fn run_server(bind_port: Option<u16>, vnt_api: VntApi) -> anyhow::Resu
log::info!("IPC Listening on {}", bound_addr);
let actual_port = bound_addr.port();
// 只有默认端口的实例才写 PORT 文件:显式指定端口的实例、以及端口
// 冲突退避到随机端口的实例都不写,避免多实例互相覆盖导致客户端
// 连错实例;写入失败不影响服务本身
if bind_port.is_none() && actual_port == DEFAULT_PORT {
let path = get_port_file_path();
fs::write(&path, actual_port.to_string())?;
if let Err(e) = fs::write(&path, actual_port.to_string()) {
log::warn!("write PORT file failed: {e:?}");
}
}
loop {
let (stream, peer_addr) = listener.accept().await?;
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "vnt-jni"
version = "2.0.2"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
vnt-core.workspace = true
jni = "0.21"
tokio = { workspace = true, features = ["full"] }
parking_lot.workspace = true
log.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
ipnet.workspace = true
lazy_static = "1.5"
hostname.workspace = true
+144
View File
@@ -0,0 +1,144 @@
import android.net.VpnService;
import android.os.ParcelFileDescriptor;
import com.vnt.*;
/**
* Android VPN服务示例
*
* 演示如何正确使用VNT JNI接口
* 1. 创建网络
* 2. 注册获取IP/掩码
* 3. 用获取的参数建立Android VPN接口
* 4. 传入tunFd启动VNT
*/
public class AndroidVpnExample extends VpnService {
private VntNetwork network;
private ParcelFileDescriptor vpnInterface;
@Override
public int onStartCommand(android.content.Intent intent, int flags, int startId) {
try {
startVpn();
return START_STICKY;
} catch (Exception e) {
e.printStackTrace();
stopSelf();
return START_NOT_STICKY;
}
}
private void startVpn() throws Exception {
// 1. 初始化VNT全局初始化只需一次
if (!VntManager.init()) {
throw new VntException("Failed to initialize VNT");
}
// 2. 构建配置
VntConfig config = new VntConfig.Builder()
.addServer("tcp://101.35.230.139:6660")
.setNetworkCode("your_network_code")
.setPassword("123456")
.setDeviceName("AndroidDevice")
.setCompress(true)
.setMtu(1380)
.build();
// 3. 创建网络实例
network = VntManager.createNetwork(config);
if (network == null) {
throw new VntException("Failed to create network");
}
// 4. 注册网络连接服务器获取分配的IP和掩码
RegisterResult result = network.register();
System.out.println("Registration successful: " + result);
// 5. 使用注册返回的IP和掩码建立Android VPN接口
VpnService.Builder builder = new Builder();
builder.setMtu(1380);
builder.addAddress(result.getIp(), result.getPrefixLen());
builder.addRoute("0.0.0.0", 0); // 全局路由
builder.setSession("VNT VPN");
// 建立VPN接口获取文件描述符
vpnInterface = builder.establish();
if (vpnInterface == null) {
throw new VntException("Failed to establish VPN interface");
}
int tunFd = vpnInterface.getFd();
System.out.println("VPN interface established, fd: " + tunFd);
// 6. 将tunFd传给VNT启动数据转发
network.startTun(tunFd);
System.out.println("VNT started successfully!");
// 7. 获取API用于查询状态
VntApi api = network.getApi();
// 8. 查询网络信息
VntApi.NetworkInfo networkInfo = api.getNetwork();
System.out.println("Network info: " + networkInfo);
// 9. 查询NAT信息
VntApi.NatInfo natInfo = api.getNatInfo();
System.out.println("NAT info: " + natInfo);
}
@Override
public void onDestroy() {
super.onDestroy();
// 清理资源
if (network != null) {
network.stop();
network = null;
}
if (vpnInterface != null) {
try {
vpnInterface.close();
} catch (Exception e) {
e.printStackTrace();
}
vpnInterface = null;
}
VntManager.destroy();
}
/**
* 查询客户端列表可在UI线程定期调用
*/
public void queryClients() {
if (network == null) {
return;
}
try {
VntApi api = network.getApi();
for (VntApi.ClientInfo client : api.getClientList()) {
System.out.println("Client: " + client);
// 检查是否直连
boolean direct = api.isDirect(client.getIp());
System.out.println(" Direct: " + direct);
// 获取丢包信息
VntApi.PacketLossInfo loss = api.getPacketLoss(client.getIp());
if (loss != null) {
System.out.println(" Packet loss: " + loss);
}
// 获取流量信息
VntApi.TrafficInfo traffic = api.getTrafficInfo(client.getIp());
if (traffic != null) {
System.out.println(" Traffic: " + traffic);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
@@ -0,0 +1,97 @@
package com.vnt;
import org.json.JSONObject;
/**
* 注册结果
*
* 包含服务器分配的IP地址掩码等信息
* 注意如果能创建此对象说明注册一定成功了失败会抛异常
*/
public class RegisterResult {
private final String ip;
private final int prefixLen;
private final String gateway;
private final String broadcast;
private RegisterResult(String ip, int prefixLen, String gateway, String broadcast) {
this.ip = ip;
this.prefixLen = prefixLen;
this.gateway = gateway;
this.broadcast = broadcast;
}
/**
* 从JSON字符串解析注册结果
* @throws VntException 如果注册失败或解析失败
*/
static RegisterResult fromJson(String json) throws VntException {
try {
JSONObject obj = new JSONObject(json);
boolean success = obj.getBoolean("success");
if (success) {
return new RegisterResult(
obj.getString("ip"),
obj.getInt("prefix_len"),
obj.getString("gateway"),
obj.getString("broadcast")
);
} else {
// 注册失败抛出异常
String error = obj.getString("error");
throw new VntException("Registration failed: " + error);
}
} catch (VntException e) {
throw e; // 重新抛出VntException
} catch (Exception e) {
throw new VntException("Failed to parse register result: " + e.getMessage(), e);
}
}
/**
* 获取分配的IP地址
*/
public String getIp() {
return ip;
}
/**
* 获取前缀长度掩码位数
*/
public int getPrefixLen() {
return prefixLen;
}
/**
* 获取网关地址
*/
public String getGateway() {
return gateway;
}
/**
* 获取广播地址
*/
public String getBroadcast() {
return broadcast;
}
/**
* 转换为CIDR格式字符串例如10.0.0.2/24
*/
public String toCidr() {
return ip + "/" + prefixLen;
}
@Override
public String toString() {
return "RegisterResult{" +
"ip='" + ip + '\'' +
", prefixLen=" + prefixLen +
", gateway='" + gateway + '\'' +
", broadcast='" + broadcast + '\'' +
'}';
}
}
+428
View File
@@ -0,0 +1,428 @@
package com.vnt;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
/**
* VNT API接口 - 用于查询网络状态和信息
*
* 通过VntNetwork.getApi()获取实例
*/
public class VntApi {
private final long nativeHandle;
// 包内构造只能通过VntNetwork创建
VntApi(long handle) {
this.nativeHandle = handle;
}
/**
* 获取客户端列表
* @return 客户端信息列表
*/
public List<ClientInfo> getClientList() throws VntException {
try {
String json = nativeGetClientList(nativeHandle);
JSONArray array = new JSONArray(json);
List<ClientInfo> clients = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject obj = array.getJSONObject(i);
clients.add(new ClientInfo(
obj.getString("ip"),
obj.getBoolean("online")
));
}
return clients;
} catch (Exception e) {
throw new VntException("Failed to get client list: " + e.getMessage(), e);
}
}
/**
* 获取当前网络配置
* @return 网络信息未连接返回null
*/
public NetworkInfo getNetwork() throws VntException {
try {
String json = nativeGetNetwork(nativeHandle);
if ("null".equals(json)) {
return null;
}
JSONObject obj = new JSONObject(json);
return new NetworkInfo(
obj.getString("ip"),
obj.getInt("prefix_len"),
obj.getString("gateway"),
obj.getString("broadcast")
);
} catch (Exception e) {
throw new VntException("Failed to get network info: " + e.getMessage(), e);
}
}
/**
* 获取本地NAT信息
* @return NAT信息未检测到返回null
*/
public NatInfo getNatInfo() throws VntException {
try {
String json = nativeGetNatInfo(nativeHandle);
if ("null".equals(json)) {
return null;
}
return NatInfo.fromJson(json);
} catch (Exception e) {
throw new VntException("Failed to get NAT info: " + e.getMessage(), e);
}
}
/**
* 获取服务器节点列表
* @return 服务器信息列表
*/
public List<ServerInfo> getServerList() throws VntException {
try {
String json = nativeGetServerList(nativeHandle);
JSONArray array = new JSONArray(json);
List<ServerInfo> servers = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject obj = array.getJSONObject(i);
servers.add(new ServerInfo(
obj.getInt("server_id"),
obj.getString("server_addr"),
obj.getBoolean("connected"),
obj.isNull("rtt") ? null : obj.getInt("rtt"),
obj.getLong("data_version"),
obj.isNull("server_version") ? null : obj.getString("server_version")
));
}
return servers;
} catch (Exception e) {
throw new VntException("Failed to get server list: " + e.getMessage(), e);
}
}
/**
* 获取路由表
* @return 路由信息列表
*/
public List<RouteInfo> getRouteTable() throws VntException {
try {
String json = nativeGetRouteTable(nativeHandle);
JSONArray array = new JSONArray(json);
List<RouteInfo> routes = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject obj = array.getJSONObject(i);
String ip = obj.getString("ip");
JSONArray routesArray = obj.getJSONArray("routes");
List<RouteDetail> details = new ArrayList<>();
for (int j = 0; j < routesArray.length(); j++) {
JSONObject route = routesArray.getJSONObject(j);
details.add(new RouteDetail(
route.getString("route_key"),
route.getString("protocol"),
route.getInt("metric"),
route.getInt("rtt")
));
}
routes.add(new RouteInfo(ip, details));
}
return routes;
} catch (Exception e) {
throw new VntException("Failed to get route table: " + e.getMessage(), e);
}
}
/**
* 检查目标IP是否直连P2P
* @param ip 目标IP地址
* @return true表示直连false表示通过服务器中转
*/
public boolean isDirect(String ip) {
return nativeIsDirect(nativeHandle, ip);
}
/**
* 获取对端NAT信息
* @param ip 目标IP地址
* @return NAT信息未知返回null
*/
public NatInfo getPeerNatInfo(String ip) throws VntException {
try {
String json = nativeGetPeerNatInfo(nativeHandle, ip);
if ("null".equals(json)) {
return null;
}
return NatInfo.fromJson(json);
} catch (Exception e) {
throw new VntException("Failed to get peer NAT info: " + e.getMessage(), e);
}
}
/**
* 获取对端丢包信息
* @param ip 目标IP地址
* @return 丢包信息未知返回null
*/
public PacketLossInfo getPacketLoss(String ip) throws VntException {
try {
String json = nativeGetPacketLoss(nativeHandle, ip);
if ("null".equals(json)) {
return null;
}
JSONObject obj = new JSONObject(json);
return new PacketLossInfo(
obj.getString("ip"),
obj.getLong("sent"),
obj.getLong("received"),
obj.getDouble("loss_rate")
);
} catch (Exception e) {
throw new VntException("Failed to get packet loss: " + e.getMessage(), e);
}
}
/**
* 获取对端流量统计
* @param ip 目标IP地址
* @return 流量信息未知返回null
*/
public TrafficInfo getTrafficInfo(String ip) throws VntException {
try {
String json = nativeGetTrafficInfo(nativeHandle, ip);
if ("null".equals(json)) {
return null;
}
JSONObject obj = new JSONObject(json);
return new TrafficInfo(
obj.getString("ip"),
obj.getLong("tx_bytes"),
obj.getLong("rx_bytes")
);
} catch (Exception e) {
throw new VntException("Failed to get traffic info: " + e.getMessage(), e);
}
}
// ========== Native 方法 ==========
private static native String nativeGetClientList(long apiHandle);
private static native String nativeGetNetwork(long apiHandle);
private static native String nativeGetNatInfo(long apiHandle);
private static native String nativeGetServerList(long apiHandle);
private static native String nativeGetRouteTable(long apiHandle);
private static native boolean nativeIsDirect(long apiHandle, String ip);
private static native String nativeGetPeerNatInfo(long apiHandle, String ip);
private static native String nativeGetPacketLoss(long apiHandle, String ip);
private static native String nativeGetTrafficInfo(long apiHandle, String ip);
// ========== 数据类 ==========
public static class ClientInfo {
private final String ip;
private final boolean online;
public ClientInfo(String ip, boolean online) {
this.ip = ip;
this.online = online;
}
public String getIp() { return ip; }
public boolean isOnline() { return online; }
@Override
public String toString() {
return "ClientInfo{ip='" + ip + "', online=" + online + "}";
}
}
public static class NetworkInfo {
private final String ip;
private final int prefixLen;
private final String gateway;
private final String broadcast;
public NetworkInfo(String ip, int prefixLen, String gateway, String broadcast) {
this.ip = ip;
this.prefixLen = prefixLen;
this.gateway = gateway;
this.broadcast = broadcast;
}
public String getIp() { return ip; }
public int getPrefixLen() { return prefixLen; }
public String getGateway() { return gateway; }
public String getBroadcast() { return broadcast; }
@Override
public String toString() {
return "NetworkInfo{ip='" + ip + "', prefixLen=" + prefixLen +
", gateway='" + gateway + "', broadcast='" + broadcast + "'}";
}
}
public static class NatInfo {
private final String natType;
private final List<String> publicIps;
private final String ipv6;
private NatInfo(String natType, List<String> publicIps, String ipv6) {
this.natType = natType;
this.publicIps = publicIps;
this.ipv6 = ipv6;
}
static NatInfo fromJson(String json) throws Exception {
JSONObject obj = new JSONObject(json);
JSONArray ipsArray = obj.getJSONArray("public_ips");
List<String> publicIps = new ArrayList<>();
for (int i = 0; i < ipsArray.length(); i++) {
publicIps.add(ipsArray.getString(i));
}
return new NatInfo(
obj.getString("nat_type"),
publicIps,
obj.isNull("ipv6") ? null : obj.getString("ipv6")
);
}
public String getNatType() { return natType; }
public List<String> getPublicIps() { return publicIps; }
public String getIpv6() { return ipv6; }
@Override
public String toString() {
return "NatInfo{natType='" + natType + "', publicIps=" + publicIps +
", ipv6='" + ipv6 + "'}";
}
}
public static class ServerInfo {
private final int serverId;
private final String serverAddr;
private final boolean connected;
private final Integer rtt;
private final long dataVersion;
private final String serverVersion;
public ServerInfo(int serverId, String serverAddr, boolean connected,
Integer rtt, long dataVersion, String serverVersion) {
this.serverId = serverId;
this.serverAddr = serverAddr;
this.connected = connected;
this.rtt = rtt;
this.dataVersion = dataVersion;
this.serverVersion = serverVersion;
}
public int getServerId() { return serverId; }
public String getServerAddr() { return serverAddr; }
public boolean isConnected() { return connected; }
public Integer getRtt() { return rtt; }
public long getDataVersion() { return dataVersion; }
public String getServerVersion() { return serverVersion; }
@Override
public String toString() {
return "ServerInfo{serverId=" + serverId + ", serverAddr='" + serverAddr +
"', connected=" + connected + ", rtt=" + rtt + "}";
}
}
public static class RouteInfo {
private final String ip;
private final List<RouteDetail> routes;
public RouteInfo(String ip, List<RouteDetail> routes) {
this.ip = ip;
this.routes = routes;
}
public String getIp() { return ip; }
public List<RouteDetail> getRoutes() { return routes; }
@Override
public String toString() {
return "RouteInfo{ip='" + ip + "', routes=" + routes + "}";
}
}
public static class RouteDetail {
private final String routeKey;
private final String protocol;
private final int metric;
private final int rtt;
public RouteDetail(String routeKey, String protocol, int metric, int rtt) {
this.routeKey = routeKey;
this.protocol = protocol;
this.metric = metric;
this.rtt = rtt;
}
public String getRouteKey() { return routeKey; }
public String getProtocol() { return protocol; }
public int getMetric() { return metric; }
public int getRtt() { return rtt; }
@Override
public String toString() {
return "RouteDetail{routeKey='" + routeKey + "', protocol='" + protocol +
"', metric=" + metric + ", rtt=" + rtt + "}";
}
}
public static class PacketLossInfo {
private final String ip;
private final long sent;
private final long received;
private final double lossRate;
public PacketLossInfo(String ip, long sent, long received, double lossRate) {
this.ip = ip;
this.sent = sent;
this.received = received;
this.lossRate = lossRate;
}
public String getIp() { return ip; }
public long getSent() { return sent; }
public long getReceived() { return received; }
public double getLossRate() { return lossRate; }
@Override
public String toString() {
return "PacketLossInfo{ip='" + ip + "', sent=" + sent +
", received=" + received + ", lossRate=" + lossRate + "}";
}
}
public static class TrafficInfo {
private final String ip;
private final long txBytes;
private final long rxBytes;
public TrafficInfo(String ip, long txBytes, long rxBytes) {
this.ip = ip;
this.txBytes = txBytes;
this.rxBytes = rxBytes;
}
public String getIp() { return ip; }
public long getTxBytes() { return txBytes; }
public long getRxBytes() { return rxBytes; }
@Override
public String toString() {
return "TrafficInfo{ip='" + ip + "', txBytes=" + txBytes +
", rxBytes=" + rxBytes + "}";
}
}
}
+325
View File
@@ -0,0 +1,325 @@
package com.vnt;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
/**
* VNT网络配置
*
* 使用Builder模式构建配置
*/
public class VntConfig {
private final List<String> servers;
private final String networkCode;
private final String password;
private final String deviceId;
private final String deviceName;
private final String tunName;
private final String outboundInterface;
private final String ip;
private final String certMode;
private final boolean noPunch;
private final boolean compress;
private final boolean rtx;
private final boolean fec;
private final boolean noNat;
private final String deviceMode;
private final Integer mtu;
private final boolean allowMapping;
private final List<String> portMapping;
private final List<String> udpStun;
private final List<String> tcpStun;
private VntConfig(Builder builder) {
this.servers = builder.servers;
this.networkCode = builder.networkCode;
this.password = builder.password;
this.deviceId = builder.deviceId;
this.deviceName = builder.deviceName;
this.tunName = builder.tunName;
this.outboundInterface = builder.outboundInterface;
this.ip = builder.ip;
this.certMode = builder.certMode;
this.noPunch = builder.noPunch;
this.compress = builder.compress;
this.rtx = builder.rtx;
this.fec = builder.fec;
this.noNat = builder.noNat;
this.deviceMode = builder.deviceMode;
this.mtu = builder.mtu;
this.allowMapping = builder.allowMapping;
this.portMapping = builder.portMapping;
this.udpStun = builder.udpStun;
this.tcpStun = builder.tcpStun;
}
/**
* 转换为JSON字符串
*/
String toJson() {
JSONObject json = new JSONObject();
// 必填项
JSONArray serverArray = new JSONArray();
for (String server : servers) {
serverArray.put(server);
}
json.put("server", serverArray);
json.put("network_code", networkCode);
// 可选项
if (password != null) json.put("password", password);
if (deviceId != null) json.put("device_id", deviceId);
if (deviceName != null) json.put("device_name", deviceName);
if (tunName != null) json.put("tun_name", tunName);
if (outboundInterface != null) json.put("outbound_interface", outboundInterface);
if (ip != null) json.put("ip", ip);
if (certMode != null) json.put("cert_mode", certMode);
if (mtu != null) json.put("mtu", mtu);
// 布尔值
json.put("no_punch", noPunch);
json.put("compress", compress);
json.put("rtx", rtx);
json.put("fec", fec);
json.put("no_nat", noNat);
json.put("device_mode", deviceMode);
json.put("allow_mapping", allowMapping);
// 数组
if (!portMapping.isEmpty()) {
JSONArray mappingArray = new JSONArray();
for (String mapping : portMapping) {
mappingArray.put(mapping);
}
json.put("port_mapping", mappingArray);
}
if (!udpStun.isEmpty()) {
JSONArray stunArray = new JSONArray();
for (String stun : udpStun) {
stunArray.put(stun);
}
json.put("udp_stun", stunArray);
}
if (!tcpStun.isEmpty()) {
JSONArray stunArray = new JSONArray();
for (String stun : tcpStun) {
stunArray.put(stun);
}
json.put("tcp_stun", stunArray);
}
return json.toString();
}
/**
* 配置构建器
*/
public static class Builder {
private List<String> servers = new ArrayList<>();
private String networkCode;
private String password;
private String deviceId;
private String deviceName;
private String tunName;
private String outboundInterface;
private String ip;
private String certMode;
private boolean noPunch = false;
private boolean compress = false;
private boolean rtx = false;
private boolean fec = false;
private boolean noNat = false;
private String deviceMode = "tun";
private Integer mtu;
private boolean allowMapping = false;
private List<String> portMapping = new ArrayList<>();
private List<String> udpStun = new ArrayList<>();
private List<String> tcpStun = new ArrayList<>();
/**
* 添加服务器地址必填
* @param server 服务器地址格式tcp://host:port wss://host:port
*/
public Builder addServer(String server) {
this.servers.add(server);
return this;
}
/**
* 设置网络代码必填
* @param networkCode 组网代码
*/
public Builder setNetworkCode(String networkCode) {
this.networkCode = networkCode;
return this;
}
/**
* 设置密码可选
*/
public Builder setPassword(String password) {
this.password = password;
return this;
}
/**
* 设置设备ID可选默认自动生成
*/
public Builder setDeviceId(String deviceId) {
this.deviceId = deviceId;
return this;
}
/**
* 设置设备名称可选
*/
public Builder setDeviceName(String deviceName) {
this.deviceName = deviceName;
return this;
}
/**
* 设置TUN设备名称可选
*/
public Builder setTunName(String tunName) {
this.tunName = tunName;
return this;
}
/**
* 绑定对外通信Socket的出口网卡名称可选
*/
public Builder setOutboundInterface(String outboundInterface) {
this.outboundInterface = outboundInterface;
return this;
}
/**
* 设置固定IP可选
*/
public Builder setIp(String ip) {
this.ip = ip;
return this;
}
/**
* 设置证书验证模式可选
* @param certMode "insecure" | "system" | "embedded"
*/
public Builder setCertMode(String certMode) {
this.certMode = certMode;
return this;
}
/**
* 禁用打洞默认false
*/
public Builder setNoPunch(boolean noPunch) {
this.noPunch = noPunch;
return this;
}
/**
* 启用压缩默认false
*/
public Builder setCompress(boolean compress) {
this.compress = compress;
return this;
}
/**
* 启用QUIC重传默认false
*/
public Builder setRtx(boolean rtx) {
this.rtx = rtx;
return this;
}
/**
* 启用FEC冗余传输默认false
*/
public Builder setFec(boolean fec) {
this.fec = fec;
return this;
}
/**
* 禁用NAT默认false
*/
public Builder setNoNat(boolean noNat) {
this.noNat = noNat;
return this;
}
/**
* 设置虚拟网卡模式notun默认 tap
*/
public Builder setDeviceMode(String deviceMode) {
if (!"no".equals(deviceMode) && !"tun".equals(deviceMode) && !"tap".equals(deviceMode)) {
throw new IllegalArgumentException("deviceMode must be no, tun, or tap");
}
this.deviceMode = deviceMode;
return this;
}
/**
* 设置MTU可选默认1380
*/
public Builder setMtu(int mtu) {
this.mtu = mtu;
return this;
}
/**
* 允许端口映射默认false
*/
public Builder setAllowMapping(boolean allowMapping) {
this.allowMapping = allowMapping;
return this;
}
/**
* 添加端口映射规则可选
* @param mapping 格式tcp:80->192.168.1.100:8080
*/
public Builder addPortMapping(String mapping) {
this.portMapping.add(mapping);
return this;
}
/**
* 添加UDP STUN服务器可选
*/
public Builder addUdpStun(String stun) {
this.udpStun.add(stun);
return this;
}
/**
* 添加TCP STUN服务器可选
*/
public Builder addTcpStun(String stun) {
this.tcpStun.add(stun);
return this;
}
/**
* 构建配置对象
*/
public VntConfig build() {
if (servers.isEmpty()) {
throw new IllegalArgumentException("At least one server must be specified");
}
if (networkCode == null || networkCode.isEmpty()) {
throw new IllegalArgumentException("Network code must be specified");
}
return new VntConfig(this);
}
}
}
@@ -0,0 +1,21 @@
package com.vnt;
/**
* VNT异常
*
* VNT操作失败时抛出的异常
*/
public class VntException extends Exception {
public VntException(String message) {
super(message);
}
public VntException(String message, Throwable cause) {
super(message, cause);
}
public VntException(Throwable cause) {
super(cause);
}
}
@@ -0,0 +1,56 @@
package com.vnt;
/**
* VNT网络管理器 - 主入口类
*
* 使用示例:
* 1. 初始化: VntManager.init()
* 2. 创建网络: VntNetwork network = VntManager.createNetwork(config)
* 3. 注册: RegisterResult result = network.register()
* 4. (Android端用result的IP/掩码创建VPN接口获取tunFd)
* 5. 启动TUN: network.startTun(tunFd)
* 6. 获取API: VntApi api = network.getApi()
* 7. 关闭: network.stop()
*/
public class VntManager {
static {
// 加载JNI库
System.loadLibrary("vnt_jni");
}
/**
* 初始化VNT模块全局初始化只需调用一次
* @return true表示成功false表示失败
*/
public static boolean init() {
return nativeInit();
}
/**
* 销毁VNT模块全局清理
*/
public static void destroy() {
nativeDestroy();
}
/**
* 创建网络实例
* @param config 网络配置对象
* @return VntNetwork实例失败返回null
*/
public static VntNetwork createNetwork(VntConfig config) {
String configJson = config.toJson();
long handle = nativeCreateNetwork(configJson);
if (handle < 0) {
return null;
}
return new VntNetwork(handle);
}
// ========== Native 方法 ==========
private static native boolean nativeInit();
private static native void nativeDestroy();
private static native long nativeCreateNetwork(String configJson);
}
@@ -0,0 +1,122 @@
package com.vnt;
/**
* VNT网络实例
*
* 代表一个VNT网络连接持有native资源
*/
public class VntNetwork {
private long nativeHandle;
private boolean closed = false;
// 包内构造只能通过VntManager创建
VntNetwork(long handle) {
this.nativeHandle = handle;
}
/**
* 注册网络连接服务器
* @return 注册结果包含分配的IP掩码等信息
* @throws VntException 注册失败时抛出异常
*/
public RegisterResult register() throws VntException {
checkClosed();
String resultJson = nativeRegister(nativeHandle);
return RegisterResult.fromJson(resultJson);
}
/**
* 启动TUN设备
* @param tunFd TUN设备文件描述符Android VpnService.Builder.establish()返回的fd
* 传入-1表示让VNT自动创建仅非Android平台支持
* @throws VntException 启动失败时抛出异常
*/
public void startTun(int tunFd) throws VntException {
checkClosed();
if (!nativeStartTun(nativeHandle, tunFd)) {
throw new VntException("Failed to start TUN device");
}
}
/**
* 设置网络IP仅非Android平台使用
* @param ip IP地址
* @param prefixLen 前缀长度
* @throws VntException 设置失败时抛出异常
*/
public void setNetworkIp(String ip, int prefixLen) throws VntException {
checkClosed();
if (!nativeSetNetworkIp(nativeHandle, ip, prefixLen)) {
throw new VntException("Failed to set network IP");
}
}
/**
* 获取VNT API实例
* @return VntApi实例
* @throws VntException 获取失败时抛出异常
*/
public VntApi getApi() throws VntException {
checkClosed();
long apiHandle = nativeGetApi(nativeHandle);
if (apiHandle < 0) {
throw new VntException("Failed to get VntApi");
}
return new VntApi(apiHandle);
}
/**
* 检查是否为无TUN模式
* @return true表示无TUN模式
*/
public boolean isNoTun() {
checkClosed();
return nativeIsNoTun(nativeHandle);
}
/**
* 停止并关闭网络
*/
public void stop() {
if (closed) {
return;
}
nativeStop(nativeHandle);
closed = true;
}
/**
* 获取native句柄供内部使用
*/
long getNativeHandle() {
return nativeHandle;
}
/**
* 检查是否已关闭
*/
private void checkClosed() {
if (closed) {
throw new IllegalStateException("VntNetwork has been closed");
}
}
@Override
protected void finalize() throws Throwable {
try {
stop();
} finally {
super.finalize();
}
}
// ========== Native 方法 ==========
private static native String nativeRegister(long handle);
private static native boolean nativeStartTun(long handle, int tunFd);
private static native boolean nativeSetNetworkIp(long handle, String ip, int prefixLen);
private static native long nativeGetApi(long handle);
private static native boolean nativeIsNoTun(long handle);
private static native boolean nativeStop(long handle);
}
+1123
View File
@@ -0,0 +1,1123 @@
use anyhow::Context;
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tokio::runtime::Runtime;
use vnt_core::api::VntApi;
use vnt_core::context::config::{Config, DeviceMode};
use vnt_core::core::{NetworkManager, RegisterResponse};
use vnt_core::nat::NetInput;
use vnt_core::port_mapping::PortMapping;
use vnt_core::tls::verifier::CertValidationMode;
use vnt_core::tunnel_core::server::transport::config::ProtocolAddress;
use vnt_core::utils::task_control::{TaskGroupGuard, TaskGroupManager};
/// 全局状态管理
struct GlobalState {
/// Tokio运行时(Arc包装以便多线程访问)
runtime: Arc<Runtime>,
/// 网络管理器实例
network_managers: HashMap<i64, Arc<Mutex<Option<NetworkManager>>>>,
/// API实例
vnt_apis: HashMap<i64, VntApi>,
/// 任务组管理器
task_group_managers: HashMap<i64, TaskGroupManager>,
/// 任务组守卫(drop 时会停止任务组,必须持有到 nativeStop
task_group_guards: HashMap<i64, TaskGroupGuard>,
/// 下一个实例ID
next_id: i64,
}
impl GlobalState {
fn new() -> anyhow::Result<Self> {
Ok(Self {
runtime: Arc::new(Runtime::new()?),
network_managers: HashMap::new(),
vnt_apis: HashMap::new(),
task_group_managers: HashMap::new(),
task_group_guards: HashMap::new(),
next_id: 1,
})
}
}
lazy_static::lazy_static! {
static ref GLOBAL_STATE: Mutex<Option<GlobalState>> = Mutex::new(None);
}
/// 从 panic payload 中提取错误消息
fn panic_message(e: Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
}
}
/// 捕获闭包内的 panic,转为 Err(消息),防止 panic 跨 FFI unwind 导致宿主 abort
fn catch_jni_panic<F, T>(f: F) -> Result<T, String>
where
F: FnOnce() -> T,
{
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_message)
}
fn encryption_state(local_key: Option<&str>, peer_key: Option<&str>) -> i32 {
match (local_key, peer_key) {
(Some(local), Some(peer)) if local == peer => 1,
(None, None) => 2,
(Some(_), None) => 3,
(None, Some(_)) => 4,
(Some(_), Some(_)) => 5,
}
}
/// JNI 导出函数的 panic 防护:panic 时向 JVM 抛出异常并返回默认值
macro_rules! jni_guard {
($env:ident, $default_ret:expr, { $($body:tt)* }) => {{
match catch_jni_panic(|| {
$($body)*
}) {
Ok(v) => v,
Err(msg) => {
let _ = $env.throw(format!("VNT native panic: {}", msg));
$default_ret
}
}
}};
}
/// 初始化JNI模块
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntManager_nativeInit(
mut env: JNIEnv,
_class: JClass,
) -> jboolean {
jni_guard!(env, 0, {
let mut state = GLOBAL_STATE.lock();
if state.is_some() {
return 1; // 已经初始化
}
match GlobalState::new() {
Ok(global_state) => {
*state = Some(global_state);
1
}
Err(e) => {
let _ = env.throw(format!("Failed to initialize VNT: {:?}", e));
0
}
}
})
}
/// 销毁JNI模块
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntManager_nativeDestroy(_env: JNIEnv, _class: JClass) {
let _ = catch_jni_panic(|| {
let mut state = GLOBAL_STATE.lock();
*state = None;
});
}
/// 创建网络实例
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntManager_nativeCreateNetwork<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
config_json: JString<'local>,
) -> jlong {
jni_guard!(env, -1, {
let result: anyhow::Result<i64> = (|| {
let mut global_state = GLOBAL_STATE.lock();
let state = global_state.as_mut().context("VNT not initialized")?;
// 解析配置JSON
let config_str: String = env.get_string(&config_json)?.into();
let config = parse_config_from_json(&config_str)?;
// 创建任务组
let task_group_manager = TaskGroupManager::new();
let (task_group, task_group_guard) = task_group_manager
.create_task()
.context("create task group")?;
// 获取runtime的clone
let runtime = state.runtime.clone();
// 创建网络管理器
let network_manager = runtime.block_on(async {
NetworkManager::create_network(Box::new(config), task_group).await
})?;
// 分配ID
let id = state.next_id;
state.next_id += 1;
// 保存实例(task_group_guard 必须随实例一直持有,drop 会停止整个任务组)
state
.network_managers
.insert(id, Arc::new(Mutex::new(Some(network_manager))));
state.task_group_managers.insert(id, task_group_manager);
state.task_group_guards.insert(id, task_group_guard);
Ok(id)
})();
match result {
Ok(id) => id,
Err(e) => {
let _ = env.throw(format!("Failed to create network: {:?}", e));
-1
}
}
})
}
/// 注册网络
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntNetwork_nativeRegister<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
handle: jlong,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let (network_manager_arc, runtime) = {
let mut global_state = GLOBAL_STATE.lock();
let state = global_state.as_mut().context("VNT not initialized")?;
let network_manager_arc = state
.network_managers
.get(&handle)
.context("Invalid handle")?
.clone();
let runtime = state.runtime.clone();
(network_manager_arc, runtime)
};
let response = {
let mut manager_lock = network_manager_arc.lock();
let manager = manager_lock
.as_mut()
.context("Network manager already destroyed")?;
runtime.block_on(async { manager.register().await })?
};
match response {
RegisterResponse::Success(network_addr) => {
let response_json = serde_json::json!({
"success": true,
"ip": network_addr.ip.to_string(),
"prefix_len": network_addr.prefix_len,
"gateway": network_addr.gateway.to_string(),
"broadcast": network_addr.broadcast.to_string(),
});
Ok(response_json.to_string())
}
RegisterResponse::Failed(error_msg) => {
let response_json = serde_json::json!({
"success": false,
"error": error_msg.message,
});
Ok(response_json.to_string())
}
}
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to register: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 启动TUN设备(Android使用,需要传入fd)
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntNetwork_nativeStartTun(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
tun_fd: jint,
) -> jboolean {
jni_guard!(env, 0, {
let result: anyhow::Result<()> = (|| {
let (network_manager_arc, runtime) = {
let mut global_state = GLOBAL_STATE.lock();
let state = global_state.as_mut().context("VNT not initialized")?;
let network_manager_arc = state
.network_managers
.get(&handle)
.context("Invalid handle")?
.clone();
let runtime = state.runtime.clone();
(network_manager_arc, runtime)
};
let mut manager_lock = network_manager_arc.lock();
let manager = manager_lock
.as_mut()
.context("Network manager already destroyed")?;
#[cfg(unix)]
{
let tun_fd = if tun_fd < 0 { None } else { Some(tun_fd) };
runtime.block_on(async { manager.start_device_fd(tun_fd).await })?;
}
#[cfg(not(unix))]
{
let _ = tun_fd; // 避免未使用警告
runtime.block_on(async { manager.start_device().await })?;
}
Ok(())
})();
match result {
Ok(_) => 1,
Err(e) => {
let _ = env.throw(format!("Failed to start TUN: {:?}", e));
0
}
}
})
}
/// 设置网络IP(非Android系统)
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntNetwork_nativeSetNetworkIp<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
handle: jlong,
ip: JString<'local>,
prefix_len: jint,
) -> jboolean {
#[cfg(target_os = "android")]
{
let _ = (handle, ip, prefix_len);
let _ = env.throw("set_network_ip is not supported on Android");
0
}
#[cfg(not(target_os = "android"))]
{
jni_guard!(env, 0, {
let result: anyhow::Result<()> = (|| {
let (network_manager_arc, runtime) = {
let mut global_state = GLOBAL_STATE.lock();
let state = global_state.as_mut().context("VNT not initialized")?;
let network_manager_arc = state
.network_managers
.get(&handle)
.context("Invalid handle")?
.clone();
let runtime = state.runtime.clone();
(network_manager_arc, runtime)
};
let ip_str: String = env.get_string(&ip)?.into();
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
let manager_lock = network_manager_arc.lock();
let manager = manager_lock
.as_ref()
.context("Network manager already destroyed")?;
runtime.block_on(async {
manager
.set_device_network_ip(ip_addr, prefix_len as u8)
.await
})?;
Ok(())
})();
match result {
Ok(_) => 1,
Err(e) => {
let _ = env.throw(format!("Failed to set network IP: {:?}", e));
0
}
}
})
}
}
/// 获取VntApi实例
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntNetwork_nativeGetApi(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jlong {
jni_guard!(env, -1, {
let result: anyhow::Result<i64> = (|| {
let mut global_state = GLOBAL_STATE.lock();
let state = global_state.as_mut().context("VNT not initialized")?;
let network_manager_arc = state
.network_managers
.get(&handle)
.context("Invalid handle")?
.clone();
let api = {
let manager_lock = network_manager_arc.lock();
let manager = manager_lock
.as_ref()
.context("Network manager already destroyed")?;
manager.vnt_api()
};
state.vnt_apis.insert(handle, api);
Ok(handle)
})();
match result {
Ok(id) => id,
Err(e) => {
let _ = env.throw(format!("Failed to get API: {:?}", e));
-1
}
}
})
}
/// 检查是否为无TUN模式
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntNetwork_nativeIsNoTun(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jboolean {
jni_guard!(env, 0, {
let result: anyhow::Result<bool> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let network_manager_arc = state
.network_managers
.get(&handle)
.context("Invalid handle")?
.clone();
let manager_lock = network_manager_arc.lock();
let manager = manager_lock
.as_ref()
.context("Network manager already destroyed")?;
Ok(manager.device_mode() == DeviceMode::No)
})();
match result {
Ok(is_no_device) => {
if is_no_device {
1
} else {
0
}
}
Err(e) => {
let _ = env.throw(format!("Failed to check device mode: {:?}", e));
0
}
}
})
}
/// 关闭网络
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntNetwork_nativeStop(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jboolean {
jni_guard!(env, 0, {
let result: anyhow::Result<()> = (|| {
let mut global_state = GLOBAL_STATE.lock();
let state = global_state.as_mut().context("VNT not initialized")?;
// 停止任务组
if let Some(task_group_manager) = state.task_group_managers.get(&handle) {
task_group_manager.stop();
}
// 移除网络管理器
state.network_managers.remove(&handle);
state.vnt_apis.remove(&handle);
state.task_group_managers.remove(&handle);
// 最后释放守卫(drop 时会停止任务组)
state.task_group_guards.remove(&handle);
Ok(())
})();
match result {
Ok(_) => 1,
Err(e) => {
let _ = env.throw(format!("Failed to stop network: {:?}", e));
0
}
}
})
}
// ==================== VntApi 接口 ====================
/// 获取客户端列表
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetClientList<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let (api, runtime) = {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
(
state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?
.clone(),
state.runtime.clone(),
)
};
let local_clients: HashMap<_, _> = api
.client_ips()
.into_iter()
.map(|client| (client.ip, client.online))
.collect();
let server_clients: HashMap<_, _> = runtime
.block_on(api.server_rpc().client_list())
.map(|response| {
response
.list
.into_iter()
.map(|client| (Ipv4Addr::from(client.ip), client))
.collect()
})
.unwrap_or_default();
let local_key = api.get_config().and_then(|config| config.key_sign());
let mut ips: Vec<_> = local_clients
.keys()
.chain(server_clients.keys())
.copied()
.collect();
ips.sort_unstable();
ips.dedup();
let json_array: Vec<_> = ips
.into_iter()
.map(|ip| {
let server_client = server_clients.get(&ip);
let route = api.find_route(&ip);
let has_route = route.is_some();
let direct = route
.as_ref()
.map(|route| route.metric() == 1)
.unwrap_or(false);
let route_protocol = route
.as_ref()
.map(|route| route.route_key().protocol().to_string());
let route_metric = route.as_ref().map(|route| route.metric());
let rtt = route.as_ref().map(|route| route.rtt());
let online = local_clients.get(&ip).copied().unwrap_or(false)
|| server_client.map(|client| client.online).unwrap_or(false)
|| has_route;
let packet_loss = api.packet_loss_info(&ip).map(|info| {
serde_json::json!({
"sent": info.sent,
"received": info.received,
"loss_rate": info.loss_rate,
})
});
let traffic = api.traffic_info(&ip).map(|info| {
serde_json::json!({
"tx_bytes": info.tx_bytes,
"rx_bytes": info.rx_bytes,
})
});
serde_json::json!({
"ip": ip.to_string(),
"name": server_client.map(|client| client.name.as_str()).unwrap_or(""),
"version": server_client.map(|client| client.version.as_str()).unwrap_or(""),
"online": online,
"direct": direct,
"route_protocol": route_protocol,
"route_metric": route_metric,
"rtt": rtt,
"key_equal": server_client
.map(|client| encryption_state(local_key.as_deref(), client.key_sign.as_deref()))
.unwrap_or(0),
"packet_loss": packet_loss,
"traffic": traffic,
})
})
.collect();
Ok(serde_json::to_string(&json_array)?)
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get client list: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 获取网络配置信息
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetNetwork<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
if let Some(network) = api.network() {
let json = serde_json::json!({
"ip": network.ip.to_string(),
"prefix_len": network.prefix_len,
"gateway": network.gateway.to_string(),
"broadcast": network.broadcast.to_string(),
});
Ok(json.to_string())
} else {
Ok("null".to_string())
}
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get network info: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 获取NAT信息
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetNatInfo<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
if let Some(nat_info) = api.nat_info() {
let json = serde_json::json!({
"nat_type": format!("{:?}", nat_info.nat_type),
"public_ips": nat_info.public_ips.iter().map(|ip| ip.to_string()).collect::<Vec<_>>(),
"ipv6": nat_info.ipv6.map(|ip| ip.to_string()),
});
Ok(json.to_string())
} else {
Ok("null".to_string())
}
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get NAT info: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 获取服务器节点列表
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetServerList<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
let servers = api.server_node_list();
let json_array: Vec<_> = servers
.into_iter()
.map(|server| {
serde_json::json!({
"server_id": server.server_id,
"server_addr": server.server_addr.to_string(),
"connected": server.connected,
"rtt": server.rtt,
"data_version": server.data_version,
"server_version": server.server_version,
})
})
.collect();
Ok(serde_json::to_string(&json_array)?)
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get server list: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 获取路由表
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetRouteTable<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
let route_table = api.route_table();
let json_data: Vec<_> = route_table
.into_iter()
.map(|(ip, routes)| {
let routes_json: Vec<_> = routes
.into_iter()
.map(|route| {
serde_json::json!({
"route_key": route.route_key().to_string(),
"protocol": route.route_key().protocol().to_string(),
"metric": route.metric(),
"rtt": route.rtt(),
})
})
.collect();
serde_json::json!({
"ip": ip.to_string(),
"routes": routes_json,
})
})
.collect();
Ok(serde_json::to_string(&json_data)?)
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get route table: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 检查目标IP是否直连
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeIsDirect<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
ip: JString<'local>,
) -> jboolean {
jni_guard!(env, 0, {
let result: anyhow::Result<bool> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
let ip_str: String = env.get_string(&ip)?.into();
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
Ok(api.is_direct(&ip_addr))
})();
match result {
Ok(is_direct) => {
if is_direct {
1
} else {
0
}
}
Err(e) => {
let _ = env.throw(format!("Failed to check direct: {:?}", e));
0
}
}
})
}
/// 获取对端NAT信息
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetPeerNatInfo<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
ip: JString<'local>,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
let ip_str: String = env.get_string(&ip)?.into();
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
if let Some(nat_info) = api.peer_nat_info(&ip_addr) {
let json = serde_json::json!({
"nat_type": format!("{:?}", nat_info.nat_type),
"public_ips": nat_info.public_ips.iter().map(|ip| ip.to_string()).collect::<Vec<_>>(),
"ipv6": nat_info.ipv6.map(|ip| ip.to_string()),
});
Ok(json.to_string())
} else {
Ok("null".to_string())
}
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get peer NAT info: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 获取丢包信息
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetPacketLoss<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
ip: JString<'local>,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
let ip_str: String = env.get_string(&ip)?.into();
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
if let Some(loss_info) = api.packet_loss_info(&ip_addr) {
let json = serde_json::json!({
"ip": loss_info.ip.to_string(),
"sent": loss_info.sent,
"received": loss_info.received,
"loss_rate": loss_info.loss_rate,
});
Ok(json.to_string())
} else {
Ok("null".to_string())
}
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get packet loss: {:?}", e));
JObject::null().into_raw()
}
}
})
}
/// 获取流量信息
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnt_VntApi_nativeGetTrafficInfo<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
api_handle: jlong,
ip: JString<'local>,
) -> jstring {
jni_guard!(env, std::ptr::null_mut(), {
let result: anyhow::Result<String> = (|| {
let global_state = GLOBAL_STATE.lock();
let state = global_state.as_ref().context("VNT not initialized")?;
let api = state
.vnt_apis
.get(&api_handle)
.context("Invalid API handle")?;
let ip_str: String = env.get_string(&ip)?.into();
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
if let Some(traffic_info) = api.traffic_info(&ip_addr) {
let json = serde_json::json!({
"ip": traffic_info.ip.to_string(),
"tx_bytes": traffic_info.tx_bytes,
"rx_bytes": traffic_info.rx_bytes,
});
Ok(json.to_string())
} else {
Ok("null".to_string())
}
})();
match result {
Ok(json_str) => env
.new_string(json_str)
.unwrap_or_else(|_| JObject::null().into())
.into_raw(),
Err(e) => {
let _ = env.throw(format!("Failed to get traffic info: {:?}", e));
JObject::null().into_raw()
}
}
})
}
// ==================== 辅助函数 ====================
/// 从JSON字符串解析配置
fn parse_config_from_json(json_str: &str) -> anyhow::Result<Config> {
#[derive(serde::Deserialize)]
struct ConfigJson {
server: Vec<String>,
network_code: String,
#[serde(default)]
device_id: Option<String>,
#[serde(default)]
device_name: Option<String>,
#[serde(default)]
tun_name: Option<String>,
#[serde(default)]
outbound_interface: Option<String>,
#[serde(default)]
ip: Option<Ipv4Addr>,
#[serde(default)]
password: Option<String>,
#[serde(default)]
cert_mode: Option<String>,
#[serde(default)]
no_punch: bool,
#[serde(default)]
compress: bool,
#[serde(default)]
rtx: bool,
#[serde(default)]
fec: bool,
#[serde(default)]
input: Vec<NetInput>,
#[serde(default)]
output: Vec<ipnet::Ipv4Net>,
#[serde(default)]
no_nat: bool,
#[serde(default)]
device_mode: DeviceMode,
#[serde(default)]
mtu: Option<u16>,
#[serde(default)]
port_mapping: Vec<String>,
#[serde(default)]
allow_mapping: bool,
#[serde(default)]
udp_stun: Vec<String>,
#[serde(default)]
tcp_stun: Vec<String>,
#[serde(default)]
tunnel_port: Option<u16>,
}
let cfg: ConfigJson = serde_json::from_str(json_str)?;
let server_addrs: Vec<ProtocolAddress> = cfg
.server
.iter()
.map(|s| {
s.parse()
.map_err(|e| anyhow::anyhow!("invalid server address '{}': {}", s, e))
})
.collect::<anyhow::Result<_>>()?;
let port_mapping: Vec<PortMapping> = cfg
.port_mapping
.iter()
.map(|s| {
s.parse()
.map_err(|e| anyhow::anyhow!("invalid port_mapping '{}': {}", s, e))
})
.collect::<anyhow::Result<_>>()?;
let cert_mode = match cfg.cert_mode.as_deref() {
Some(s) => s
.parse()
.map_err(|e| anyhow::anyhow!("invalid cert_mode '{}': {}", s, e))?,
None => CertValidationMode::InsecureSkipVerification,
};
let device_id = match cfg.device_id {
Some(id) => id,
None => vnt_core::utils::device_id::get_device_id()
.map_err(|e| anyhow::anyhow!("failed to get device_id: {}", e))?,
};
let device_name = cfg.device_name.unwrap_or_else(|| {
hostname::get()
.ok()
.and_then(|v| v.into_string().ok())
.unwrap_or_default()
});
let mut udp_stun = cfg.udp_stun;
for x in udp_stun.iter_mut() {
if !x.contains(':') {
x.push_str(":3478");
}
}
let mut tcp_stun = cfg.tcp_stun;
for x in tcp_stun.iter_mut() {
if !x.contains(':') {
x.push_str(":3478");
}
}
Ok(Config {
server_addr: server_addrs,
network_code: cfg.network_code,
ip: cfg.ip,
no_punch: cfg.no_punch,
rtx: cfg.rtx,
compress: cfg.compress,
device_id,
device_name,
tun_name: cfg.tun_name,
outbound_interface: cfg.outbound_interface,
password: cfg.password,
cert_mode,
input: cfg.input,
output: cfg.output,
no_nat: cfg.no_nat,
device_mode: cfg.device_mode,
mtu: cfg.mtu,
port_mapping,
allow_port_mapping: cfg.allow_mapping,
udp_stun,
tcp_stun,
fec: cfg.fec,
tunnel_port: cfg.tunnel_port,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catch_jni_panic_returns_value_unchanged() {
let result = catch_jni_panic(|| 42);
assert_eq!(result, Ok(42));
}
#[test]
fn catch_jni_panic_captures_str_message() {
let result: Result<(), String> = catch_jni_panic(|| panic!("boom"));
let err = result.unwrap_err();
assert!(err.contains("boom"), "unexpected message: {}", err);
}
#[test]
fn catch_jni_panic_captures_string_message() {
let result: Result<(), String> = catch_jni_panic(|| panic!("{}", "kaboom"));
assert_eq!(result.unwrap_err(), "kaboom");
}
#[test]
fn maps_peer_encryption_states() {
assert_eq!(encryption_state(Some("same"), Some("same")), 1);
assert_eq!(encryption_state(None, None), 2);
assert_eq!(encryption_state(Some("local"), None), 3);
assert_eq!(encryption_state(None, Some("peer")), 4);
assert_eq!(encryption_state(Some("local"), Some("peer")), 5);
}
}
+17 -14
View File
@@ -1,23 +1,26 @@
[package]
name = "vnt-web"
version = "2.0.0"
version = "2.0.2"
edition = "2024"
[dependencies]
vnt-core = { path = "../vnt-core" }
tokio = { version = "1", features = ["full"] }
vnt-core.workspace = true
tokio = { workspace = true, features = ["full"] }
axum = "0.8.8"
tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
tower-http = { version = "0.7", features = ["cors"] }
tower = { version = "0.5", features = ["util"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0.100"
log = "0.4.29"
ipnet = "2.11.0"
hostname = "0.4.2"
route_manager = "0.2.11"
toml = "0.9.8"
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
log.workspace = true
ipnet.workspace = true
hostname.workspace = true
route_manager.workspace = true
toml.workspace = true
rust-embed = "8.0"
mime_guess = "2.0"
parking_lot = "0.12"
time = { version = "0.3.45", features = ["local-offset", "formatting", "macros"] }
parking_lot.workspace = true
time = { workspace = true, features = ["local-offset", "formatting", "macros"] }
tokio-util.workspace = true
rand = "0.10"
+143
View File
@@ -0,0 +1,143 @@
//! 构建 vnt-web 前自动构建前端 UIvnt-web/ui -> vnt-web/static)。
//!
//! - 前端源码(ui/src 等)比 static 产物新、或产物缺失时,调用 pnpm 构建
//! - 产物已是最新则跳过,不拖慢增量编译
//! - 找不到 pnpm 时:已有产物则告警并沿用;没有产物则报错并给出指引
//! - 设置环境变量 VNT_WEB_SKIP_UI_BUILD=1 可完全跳过前端构建
use std::path::Path;
use std::process::Command;
use std::time::SystemTime;
fn main() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let manifest_dir = Path::new(&manifest_dir);
let ui_dir = manifest_dir.join("ui");
let static_dir = manifest_dir.join("static");
let workspace_root = manifest_dir.parent().expect("workspace root");
// UI 源码变化时重新运行本脚本
println!("cargo:rerun-if-changed={}", ui_dir.join("src").display());
println!(
"cargo:rerun-if-changed={}",
ui_dir.join("index.html").display()
);
println!(
"cargo:rerun-if-changed={}",
ui_dir.join("vite.config.js").display()
);
println!(
"cargo:rerun-if-changed={}",
ui_dir.join("package.json").display()
);
// 产物被删除时也要重新运行
println!(
"cargo:rerun-if-changed={}",
static_dir.join("index.html").display()
);
println!("cargo:rerun-if-env-changed=VNT_WEB_SKIP_UI_BUILD");
if std::env::var("VNT_WEB_SKIP_UI_BUILD").is_ok() {
ensure_static_placeholder(&static_dir);
return;
}
if static_is_fresh(&ui_dir, &static_dir) {
return;
}
let Some(pnpm) = find_pnpm() else {
if static_dir.join("index.html").is_file() {
println!(
"cargo:warning=未找到 pnpm,沿用 vnt-web/static 中已有的前端产物(可能不是最新)"
);
return;
}
panic!(
"未找到 pnpm 且 vnt-web/static 没有前端产物。\n\
Node.js pnpm cargo \n\
static vnt-web/"
);
};
if !ui_dir.join("node_modules").is_dir() {
// ui 依赖使用 workspace catalog,必须在仓库根目录安装
run_or_panic(pnpm, &["install", "--frozen-lockfile"], workspace_root);
}
run_or_panic(pnpm, &["--filter", "vnt-web-ui", "build"], workspace_root);
}
/// pnpm 命令名(Windows 上是 pnpm.cmd,由 cmd.exe 执行)
fn find_pnpm() -> Option<&'static str> {
let candidates: &[&str] = if cfg!(windows) {
&["pnpm.cmd", "pnpm"]
} else {
&["pnpm"]
};
candidates
.iter()
.copied()
.find(|cmd| Command::new(cmd).arg("--version").output().is_ok())
}
fn run_or_panic(program: &str, args: &[&str], dir: &Path) {
println!(
"cargo:warning=执行前端构建: {} {} ({})",
program,
args.join(" "),
dir.display()
);
let status = Command::new(program)
.args(args)
.current_dir(dir)
.status()
.unwrap_or_else(|e| panic!("执行 {} 失败: {}", program, e));
if !status.success() {
panic!(
"前端构建失败: {} {} (exit: {:?})",
program,
args.join(" "),
status.code()
);
}
}
/// static 产物是否比 UI 源码新
fn static_is_fresh(ui_dir: &Path, static_dir: &Path) -> bool {
let Ok(built_at) = std::fs::metadata(static_dir.join("index.html")).and_then(|m| m.modified())
else {
return false;
};
newest_mtime(&ui_dir.join("src")).is_none_or(|t| t <= built_at)
}
fn newest_mtime(dir: &Path) -> Option<SystemTime> {
let mut newest: Option<SystemTime> = None;
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).ok()?.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(m) = entry.metadata().and_then(|m| m.modified()) {
newest = Some(newest.map_or(m, |n| n.max(m)));
}
}
}
newest
}
/// 跳过构建时保证 static/ 存在,使 rust_embed 可以编译
fn ensure_static_placeholder(static_dir: &Path) {
if static_dir.join("index.html").is_file() {
return;
}
println!("cargo:warning=VNT_WEB_SKIP_UI_BUILD 已设置且 static 为空,写入占位页面");
std::fs::create_dir_all(static_dir).expect("创建 static 目录失败");
std::fs::write(
static_dir.join("index.html"),
"<!doctype html><html><body><p>VNT Web UI 未构建。请安装 pnpm 后重新执行 cargo build\
VNT_WEB_SKIP_UI_BUILD</p></body></html>",
)
.expect("写入占位页面失败");
}
+1 -1
View File
@@ -1,6 +1,6 @@
mod service_http;
pub use service_http::run_http_server;
pub use service_http::{VntService, generate_access_token, run_http_server};
struct ScopeGuard<F: FnOnce()>(Option<F>);
+1006 -143
View File
@@ -1,18 +1,19 @@
use crate::defer;
use anyhow::{Context, anyhow};
use axum::body::Body;
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
use anyhow::{Context, anyhow, bail};
use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
use axum::response::IntoResponse;
use axum::{
Json, Router,
extract::{Query, Request, State},
middleware,
response::Response,
routing::{get, post},
routing::{delete, get, post},
};
use ipnet::Ipv4Net;
use mime_guess::from_path;
use parking_lot::Mutex;
use rand::RngExt;
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -23,10 +24,12 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
use time::{OffsetDateTime, format_description};
use tokio::fs;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
use tower::ServiceExt;
use tower_http::cors::{Any, CorsLayer};
use vnt_core::api::VntApi;
use vnt_core::context::config::Config as CoreConfig;
use vnt_core::core::{DEFAULT_MTU, NetworkManager};
use vnt_core::context::config::{Config as CoreConfig, DeviceMode};
use vnt_core::core::{DEFAULT_MTU, NetworkManager, RegisterResponse};
use vnt_core::nat::NetInput;
use vnt_core::port_mapping::PortMapping;
use vnt_core::tls::verifier::CertValidationMode;
@@ -36,7 +39,7 @@ use vnt_core::utils::task_control::TaskGroupManager;
const CONFIG_DIR: &str = "vnt_config";
const CURRENT_CONFIG_RECORD: &str = "vnt_current_config.txt";
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "lowercase")]
enum VntStatus {
#[default]
@@ -47,77 +50,157 @@ enum VntStatus {
#[derive(Clone)]
struct HttpAppState {
task_group_manager: TaskGroupManager,
inner: Arc<Mutex<HttpAppStateInner>>,
}
#[derive(Default)]
struct HttpAppStateInner {
/// 组网实例表,key = 配置文件名,同一配置最多一个实例
instances: HashMap<String, InstanceState>,
}
#[derive(Default)]
struct InstanceState {
vnt: Option<VntHandler>,
status: VntStatus,
start_logs: Vec<String>,
/// 启动任务句柄,用于在 Starting 状态中断注册重试循环
start_handle: Option<tokio::task::JoinHandle<()>>,
/// 每个实例持有自己的任务组管理器(TaskGroupManager 是单槽的,不能共享)
task_group_manager: TaskGroupManager,
/// 启动时解析出的配置快照,用于多实例启动前冲突检测
start_config: Option<StartConfig>,
/// 展示名;Starting 阶段还没有 vnt,用配置里的 config_name 或 file_name 兜底
config_name: String,
}
impl HttpAppState {
fn starting(&self) -> anyhow::Result<()> {
fn starting(&self, file_name: &str) -> anyhow::Result<()> {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Stopped {
return Err(anyhow!("VNT is already starting or running"));
let inst = inner.instances.entry(file_name.to_string()).or_default();
if inst.status != VntStatus::Stopped {
return Err(anyhow!("配置 {} 正在启动或已运行", file_name));
}
if inner.vnt.is_some() {
return Err(anyhow!("VNT is already running"));
if inst.vnt.is_some() {
return Err(anyhow!("配置 {} 已在运行", file_name));
}
inner.status = VntStatus::Starting;
inner.start_logs.clear();
inst.status = VntStatus::Starting;
inst.start_logs.clear();
inst.start_config = None;
inst.config_name = file_name.to_string();
Ok(())
}
fn stopped(&self) {
fn stopped(&self, file_name: &str) {
let mut inner = self.inner.lock();
inner.vnt.take();
inner.status = VntStatus::Stopped;
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
inst.vnt.take();
inst.status = VntStatus::Stopped;
inst.start_config = None;
// 已完成任务的句柄只是残留,不算运行内容
if inst.start_handle.as_ref().is_some_and(|h| h.is_finished()) {
inst.start_handle.take();
}
fn starting_to_stopped(&self) {
// 实例已无任何运行内容时移除条目,避免实例表堆积已停止的配置。
// 注意 Starting 失败路径走 record_log_and_stopped/starting_to_stopped 保留日志,
// 不经过这里,不会被误删。
let removable = inst.start_handle.is_none() && inst.task_group_manager.is_stopped();
if removable {
inner.instances.remove(file_name);
}
}
fn starting_to_stopped(&self, file_name: &str) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inner.vnt.take();
inner.status = VntStatus::Stopped;
inner
.start_logs
inst.vnt.take();
inst.status = VntStatus::Stopped;
inst.start_logs
.push(format!("[{}] 启动中断", HttpAppState::timestamp()));
}
fn starting_to_running(&self) {
fn starting_to_running(&self, file_name: &str) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
log::error!("starting_to_running VNT is not starting");
return;
}
inner.status = VntStatus::Running;
inner.start_logs.clear();
inst.status = VntStatus::Running;
inst.start_logs.clear();
}
fn record_log(&self, msg: impl Into<String>) {
fn record_log(&self, file_name: &str, msg: impl Into<String>) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inner
.start_logs
inst.start_logs
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
}
fn record_log_and_stopped(&self, msg: impl Into<String>) {
fn record_log_and_stopped(&self, file_name: &str, msg: impl Into<String>) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inner
.start_logs
inst.start_logs
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
inner.status = VntStatus::Stopped;
inst.status = VntStatus::Stopped;
}
fn status(&self, file_name: &str) -> VntStatus {
self.inner
.lock()
.instances
.get(file_name)
.map(|inst| inst.status)
.unwrap_or(VntStatus::Stopped)
}
fn task_group_manager(&self, file_name: &str) -> Option<TaskGroupManager> {
self.inner
.lock()
.instances
.get(file_name)
.map(|inst| inst.task_group_manager.clone())
}
/// 启动解析出配置后写入展示名和配置快照(供实例列表与冲突检测使用)
fn set_starting_config(&self, file_name: &str, config_name: String, cfg: StartConfig) {
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
inst.config_name = config_name;
inst.start_config = Some(cfg);
}
}
fn set_start_handle(&self, file_name: &str, handle: tokio::task::JoinHandle<()>) {
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
inst.start_handle = Some(handle);
}
}
/// 中断启动任务(如注册重试循环)。任务已完成时为空操作。
fn abort_start_task(&self, file_name: &str) {
let handle = self
.inner
.lock()
.instances
.get_mut(file_name)
.and_then(|inst| inst.start_handle.take());
if let Some(handle) = handle {
handle.abort();
}
fn status(&self) -> VntStatus {
self.inner.lock().status
}
fn timestamp() -> String {
@@ -132,6 +215,8 @@ struct VntHandler {
api: VntApi,
config_name: String,
config_file_name: String,
/// 启动时的配置快照,用于多实例冲突检测
start_config: StartConfig,
}
#[derive(Serialize)]
@@ -159,7 +244,7 @@ impl<T> ApiResponse<T> {
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct StartConfig {
pub config_name: Option<String>,
pub server: Vec<String>,
@@ -168,6 +253,7 @@ pub struct StartConfig {
pub device_id: Option<String>,
pub device_name: Option<String>,
pub tun_name: Option<String>,
pub outbound_interface: Option<String>,
pub ip: Option<Ipv4Addr>,
pub password: Option<String>,
#[serde(default)]
@@ -185,7 +271,9 @@ pub struct StartConfig {
#[serde(default)]
pub no_nat: bool,
#[serde(default)]
pub no_tun: bool,
pub device_mode: DeviceMode,
#[serde(default, rename = "no_tun", skip_serializing)]
pub legacy_no_tun: Option<bool>,
pub mtu: Option<u16>,
#[serde(default)]
pub port_mapping: Vec<String>,
@@ -195,6 +283,16 @@ pub struct StartConfig {
pub udp_stun: Vec<String>,
#[serde(default)]
pub tcp_stun: Vec<String>,
pub tunnel_port: Option<u16>,
}
impl StartConfig {
fn reject_legacy_no_tun(&self) -> anyhow::Result<()> {
if self.legacy_no_tun.is_some() {
bail!("configuration key 'no_tun' was removed; use device_mode = \"no|tun|tap\"")
}
Ok(())
}
}
#[derive(Deserialize)]
@@ -238,6 +336,8 @@ struct HttpAppInfo {
compress: Option<bool>,
encrypt: Option<bool>,
rtx: Option<bool>,
/// 启动后配置文件是否发生过变化(与启动时的配置快照对比)
config_changed: bool,
}
#[derive(Serialize)]
@@ -294,6 +394,7 @@ struct HttpRouteDetail {
protocol: String,
metric: u8,
rtt: u32,
loss_rate: u16,
}
#[derive(Serialize)]
@@ -302,14 +403,61 @@ struct StartStatusResponse {
logs: Vec<String>,
}
#[derive(Serialize)]
struct InstanceSummary {
file_name: String,
config_name: String,
status: VntStatus,
}
async fn get_start_status(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<StartStatusResponse>> {
let lock = state.inner.lock();
Json(ApiResponse::success(StartStatusResponse {
status: lock.status,
logs: lock.start_logs.clone(),
}))
// 实例不存在(从未启动或已停止并清理)时返回 Stopped + 空日志,
// 前端轮询已停止实例时自然终止
let resp = match lock.instances.get(&req.file_name) {
Some(inst) => StartStatusResponse {
status: inst.status,
logs: inst.start_logs.clone(),
},
None => StartStatusResponse {
status: VntStatus::Stopped,
logs: Vec::new(),
},
};
Json(ApiResponse::success(resp))
}
async fn get_instances(
State(state): State<HttpAppState>,
) -> Json<ApiResponse<Vec<InstanceSummary>>> {
let lock = state.inner.lock();
let mut list: Vec<InstanceSummary> = lock
.instances
.iter()
.map(|(file_name, inst)| {
let config_name = inst
.vnt
.as_ref()
.map(|v| v.config_name.clone())
.unwrap_or_else(|| {
if inst.config_name.is_empty() {
file_name.clone()
} else {
inst.config_name.clone()
}
});
InstanceSummary {
file_name: file_name.clone(),
config_name,
status: inst.status,
}
})
.collect();
list.sort_by(|a, b| a.file_name.cmp(&b.file_name));
Json(ApiResponse::success(list))
}
async fn logging_middleware(req: Request, next: axum::middleware::Next) -> Response {
@@ -331,23 +479,49 @@ async fn logging_middleware(req: Request, next: axum::middleware::Next) -> Respo
#[folder = "static/"]
struct Asset;
pub async fn run_http_server(
addr: SocketAddr,
/// 进程内 VNT 业务服务。HTTP 和 Tauri IPC 共用同一组 handler 与状态。
#[derive(Clone)]
pub struct VntService {
router: Router,
}
#[derive(Clone, Copy)]
enum ServiceRuntime {
StandaloneWeb,
DesktopWeb,
}
impl ServiceRuntime {
fn as_str(self) -> &'static str {
match self {
Self::StandaloneWeb => "standalone_web",
Self::DesktopWeb => "desktop_web",
}
}
}
impl VntService {
pub async fn new(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
Self::new_with_runtime(start_config_file_name, ServiceRuntime::StandaloneWeb).await
}
pub async fn new_desktop(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
Self::new_with_runtime(start_config_file_name, ServiceRuntime::DesktopWeb).await
}
async fn new_with_runtime(
start_config_file_name: Option<PathBuf>,
) -> anyhow::Result<()> {
runtime: ServiceRuntime,
) -> anyhow::Result<Self> {
fs::create_dir_all(CONFIG_DIR)
.await
.context("Failed to create config directory")?;
let state = HttpAppState {
task_group_manager: TaskGroupManager::new(),
inner: Arc::new(Default::default()),
};
// 自动启动逻辑
let auto_start_file = determine_auto_start_file(start_config_file_name).await;
if let Some((file_name, path)) = auto_start_file {
for (file_name, path) in determine_auto_start_files(start_config_file_name).await {
log::info!("Auto starting VNT with config: {:?}", path);
let state_clone = state.clone();
tokio::spawn(async move {
@@ -357,16 +531,70 @@ pub async fn run_http_server(
});
}
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Ok(Self {
router: api_router(state, runtime),
})
}
let app = Router::new()
/// 由 Tauri command 调用,不经过 TCP/HTTP 监听端口。
pub async fn request(
&self,
method: &str,
path: &str,
body: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let method = Method::from_bytes(method.as_bytes()).context("Invalid request method")?;
let request = axum::http::Request::builder()
.method(method)
.uri(path)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.unwrap_or_default()))?;
let response = self.router.clone().oneshot(request).await?;
let status = response.status();
let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024).await?;
let value: serde_json::Value = serde_json::from_slice(&bytes)
.with_context(|| format!("Invalid service response ({status})"))?;
Ok(value)
}
/// 在当前进程中按需开放带令牌鉴权的 Web 服务。
pub async fn start_http(
&self,
addr: SocketAddr,
token: String,
cancellation: CancellationToken,
) -> anyhow::Result<tokio::task::JoinHandle<anyhow::Result<()>>> {
let listener = TcpListener::bind(addr).await?;
let actual_addr = listener.local_addr()?;
let app = http_router(self.router.clone(), token);
log::info!("HTTP API Listening on http://{}", actual_addr);
Ok(tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(cancellation.cancelled_owned())
.await?;
Ok(())
}))
}
}
pub fn generate_access_token() -> String {
let mut bytes = [0_u8; 24];
rand::rng().fill(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn api_router(state: HttpAppState, runtime: ServiceRuntime) -> Router {
let get_runtime =
move || async move { Json(ApiResponse::success(runtime.as_str().to_string())) };
Router::new()
.route("/api/version", get(get_version))
.route("/api/runtime", get(get_runtime))
.route("/api/info", get(get_info))
.route("/api/peers", get(get_peers))
.route("/api/routes", get(get_routes))
.route("/api/start/status", get(get_start_status))
.route("/api/instances", get(get_instances))
.route("/api/instance", delete(dismiss_instance_handler))
.route("/api/start", post(start_vnt_handler))
.route("/api/stop", post(stop_vnt_handler))
.route("/api/restart", post(restart_vnt_handler))
@@ -375,45 +603,139 @@ pub async fn run_http_server(
"/api/config",
get(get_config).post(save_config).delete(delete_config),
)
.with_state(state)
}
async fn token_auth_middleware(
State(token): State<String>,
req: Request,
next: axum::middleware::Next,
) -> Response {
let authorized = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|provided| provided == token);
if !authorized {
return (
StatusCode::UNAUTHORIZED,
Json(ApiResponse::<()>::error("访问令牌无效或已过期")),
)
.into_response();
}
next.run(req).await
}
fn http_router(api: Router, token: String) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.merge(api.layer(middleware::from_fn_with_state(token, token_auth_middleware)))
.fallback(static_handler)
.layer(cors)
.layer(middleware::from_fn(logging_middleware))
.with_state(state)
.fallback(static_handler);
}
log::info!("HTTP API Listening on http://{}", addr);
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
pub async fn run_http_server(
addr: SocketAddr,
start_config_file_name: Option<PathBuf>,
token: String,
) -> anyhow::Result<()> {
let service = VntService::new(start_config_file_name).await?;
let cancellation = CancellationToken::new();
let handle = service
.start_http(addr, token, cancellation.clone())
.await?;
shutdown_signal().await;
cancellation.cancel();
handle.await??;
Ok(())
}
/// 确定自动启动的配置文件
async fn determine_auto_start_file(
/// 确定自动启动的配置文件列表。
/// --conf 显式指定时只返回那一个;否则读自启记录文件(每行一个 file_name),过滤存在的文件。
async fn determine_auto_start_files(
start_config_file_name: Option<PathBuf>,
) -> Option<(String, PathBuf)> {
let path = if let Some(name) = start_config_file_name {
Some(name)
) -> Vec<(String, PathBuf)> {
let mut result = Vec::new();
let paths: Vec<PathBuf> = if let Some(name) = start_config_file_name {
vec![name]
} else if Path::new(CURRENT_CONFIG_RECORD).exists() {
fs::read_to_string(CURRENT_CONFIG_RECORD)
.await
.ok()
.filter(|content| !content.trim().is_empty())
.map(|content| Path::new(CONFIG_DIR).join(content.trim()))
match fs::read_to_string(CURRENT_CONFIG_RECORD).await {
Ok(content) => content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| Path::new(CONFIG_DIR).join(line))
.collect(),
Err(e) => {
log::warn!("Failed to read auto start record: {}", e);
Vec::new()
}
}
} else {
None
Vec::new()
};
path.and_then(|p| {
let file_name = p.file_name()?.to_str()?.to_string();
for p in paths {
let Some(file_name) = p
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
else {
continue;
};
if result.iter().any(|(name, _)| *name == file_name) {
continue;
}
if p.exists() {
Some((file_name, p))
result.push((file_name, p));
} else {
log::warn!("Auto start config file not found: {:?}", p);
None
}
})
}
result
}
/// 读取自启记录文件(每行一个 file_name,去空白、去重)
async fn read_running_records() -> Vec<String> {
let Ok(content) = fs::read_to_string(CURRENT_CONFIG_RECORD).await else {
return Vec::new();
};
let mut names: Vec<String> = Vec::new();
for line in content.lines() {
let name = line.trim();
if !name.is_empty() && !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
async fn write_running_records(names: &[String]) {
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, names.join("\n")).await {
log::warn!("Failed to record running configs: {}", e);
}
}
/// 启动成功后把 file_name 加入自启记录
async fn record_add_running(file_name: &str) {
let mut names = read_running_records().await;
if !names.iter().any(|n| n == file_name) {
names.push(file_name.to_string());
}
write_running_records(&names).await;
}
/// 实例停止后把 file_name 从自启记录移除
async fn record_remove_running(file_name: &str) {
let mut names = read_running_records().await;
names.retain(|n| n != file_name);
write_running_records(&names).await;
}
fn build_headers_for_path(path: &str) -> HeaderMap {
@@ -442,12 +764,28 @@ fn build_headers_for_path(path: &str) -> HeaderMap {
);
headers
}
/// 将请求路径安全地映射到 static 目录内。
/// 逐组件校验,拒绝 `..`、根路径、盘符等任何可能逃逸出 static 的路径。
fn resolve_static_path(path: &str) -> Option<PathBuf> {
let mut local_path = PathBuf::from("static");
for component in Path::new(path).components() {
match component {
std::path::Component::Normal(part) => local_path.push(part),
std::path::Component::CurDir => {}
_ => return None,
}
}
Some(local_path)
}
async fn static_handler(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
// 先尝试从本地文件读取
let local_path = Path::new("static").join(path);
let Some(local_path) = resolve_static_path(path) else {
return (StatusCode::NOT_FOUND, "404 Not Found").into_response();
};
if local_path.is_file()
&& let Ok(content) = tokio::fs::read(&local_path).await
{
@@ -466,6 +804,40 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
(StatusCode::NOT_FOUND, "404 Not Found").into_response()
}
/// 启动前冲突检测:新配置与所有 Starting/Running 实例的配置比对。
/// 纯函数,便于单元测试。
fn check_config_conflict(new: &StartConfig, running: &[&StartConfig]) -> Result<(), String> {
for cfg in running {
// device_id 的唯一性只在"同一服务器 + 同一组网编号"范围内成立:
// 不同服务器或不同 network_code 的实例互不影响
let same_network = new.network_code == cfg.network_code;
let server_overlap = (new.server.is_empty() && cfg.server.is_empty())
|| new.server.iter().any(|s| cfg.server.contains(s));
// 两者都为 None 也算冲突:缺省 device_id 使用同一 machine_uid
if same_network && server_overlap && new.device_id == cfg.device_id {
return Err(match &new.device_id {
Some(id) => format!(
"启动冲突:device_id \"{}\" 已被同服务器同组网的运行中实例使用",
id
),
None => {
"启动冲突:与同服务器同组网的实例都未指定 device_id,缺省会使用相同的本机标识"
.to_string()
}
});
}
if let (Some(a), Some(b)) = (new.tunnel_port, cfg.tunnel_port)
&& a == b
{
return Err(format!(
"启动冲突:tunnel_port {} 已被其他运行中的实例使用",
a
));
}
}
Ok(())
}
/// 启动 VNT 服务的入口函数
async fn start_vnt_internal(
state: &HttpAppState,
@@ -473,44 +845,70 @@ async fn start_vnt_internal(
file_path: PathBuf,
) -> anyhow::Result<()> {
log::info!("Starting VNT service: {}", file_name);
state.starting()?;
state.starting(&file_name)?;
let state_for_error = state.clone();
let file_name_for_error = file_name.clone();
let on_error_guard = defer(move || {
state_for_error.starting_to_stopped();
state_for_error.starting_to_stopped(&file_name_for_error);
});
state.record_log(format!("启动配置: {}", file_name));
state.record_log("读取配置文件");
state.record_log(&file_name, format!("启动配置: {}", file_name));
state.record_log(&file_name, "读取配置文件");
// 读取并解析配置
let content = fs::read_to_string(&file_path)
.await
.with_context(|| format!("Config file not found: {:?}", file_path))?;
state.record_log("解析配置文件内容");
state.record_log(&file_name, "解析配置文件内容");
let cfg: StartConfig = toml::from_str(&content).context("Failed to parse TOML config")?;
let config_display_name = cfg.config_name.clone().unwrap_or_else(|| file_name.clone());
let core_config = convert_config(cfg)?;
let sub_input = core_config.input.clone();
state.record_log("创建异步任务组");
let (task_group, task_group_guard) = state
.task_group_manager
// 启动前冲突检测:与所有 Starting/Running 实例的配置比对
{
let inner = state.inner.lock();
let running: Vec<&StartConfig> = inner
.instances
.iter()
.filter(|(name, inst)| name.as_str() != file_name && inst.status != VntStatus::Stopped)
.filter_map(|(_, inst)| {
inst.vnt
.as_ref()
.map(|v| &v.start_config)
.or(inst.start_config.as_ref())
})
.collect();
if let Err(msg) = check_config_conflict(&cfg, &running) {
bail!(msg);
}
}
state.set_starting_config(&file_name, config_display_name.clone(), cfg.clone());
let start_config = cfg.clone();
let core_config = convert_config(cfg)?;
state.record_log(&file_name, "创建异步任务组");
let task_group_manager = state
.task_group_manager(&file_name)
.context("Instance not found")?;
let (task_group, task_group_guard) = task_group_manager
.create_task()
.context("Create task failed")?;
state.record_log("创建组网管理器");
state.record_log(&file_name, "创建组网管理器");
let state_clone = state.clone();
tokio::spawn(async move {
let file_name_clone = file_name.clone();
let start_handle = tokio::spawn(async move {
let result = start_vnt_network(
state_clone.clone(),
file_name,
file_name_clone.clone(),
config_display_name,
start_config,
core_config,
sub_input,
task_group,
task_group_guard,
)
@@ -518,10 +916,11 @@ async fn start_vnt_internal(
if let Err(e) = result {
log::error!("Failed to start VNT network: {:?}", e);
state_clone.record_log_and_stopped(format!("启动失败: {}", e));
state_clone.record_log_and_stopped(&file_name_clone, format!("启动失败: {}", e));
}
drop(on_error_guard);
});
state.set_start_handle(&file_name, start_handle);
Ok(())
}
@@ -531,12 +930,14 @@ async fn start_vnt_network(
state: HttpAppState,
file_name: String,
config_display_name: String,
start_config: StartConfig,
core_config: CoreConfig,
sub_input: Vec<NetInput>,
task_group: vnt_core::utils::task_control::TaskGroup,
task_group_guard: vnt_core::utils::task_control::TaskGroupGuard,
) -> anyhow::Result<()> {
let mut network_manager = NetworkManager::create_network(Box::new(core_config), task_group.clone())
let sub_input = core_config.input.clone();
let mut network_manager =
NetworkManager::create_network(Box::new(core_config), task_group.clone())
.await
.map_err(|e| anyhow!("Create network failed: {:?}", e))?;
@@ -544,47 +945,71 @@ async fn start_vnt_network(
{
let mut lock = state.inner.lock();
if lock.vnt.is_some() {
let Some(inst) = lock.instances.get_mut(&file_name) else {
return Err(anyhow!("Instance not found: {}", file_name));
};
if inst.vnt.is_some() {
return Err(anyhow!("VNT is already running"));
}
lock.vnt = Some(VntHandler {
inst.vnt = Some(VntHandler {
api: vnt_api,
config_name: config_display_name,
config_file_name: file_name.clone(),
start_config,
});
}
let state_for_vnt_cleanup = state.clone();
let file_name_for_cleanup = file_name.clone();
let vnt_cleanup_guard = defer(move || {
state_for_vnt_cleanup.stopped();
state_for_vnt_cleanup.stopped(&file_name_for_cleanup);
});
state.record_log("连接服务器,执行注册");
state.record_log(&file_name, "连接服务器,执行注册");
log::info!("Registering with server");
let reg_msg = network_manager
.register()
.await
.context("Registration failed")?;
state.record_log(format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len));
let reg_msg = loop {
let reg_msg = match network_manager.register().await {
Ok(rs) => rs,
Err(e) => {
log::error!("Register failed: {:?}", e);
state.record_log(&file_name, format!("注册失败:{},5秒后重试", e));
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
continue;
}
};
match reg_msg {
RegisterResponse::Success(reg_msg) => {
break reg_msg;
}
RegisterResponse::Failed(e) => {
log::error!("Register failed: {:?}", e);
bail!("注册失败:{}", e.message)
}
}
};
state.record_log(
&file_name,
format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len),
);
log::info!("Network Started: {}/{}", reg_msg.ip, reg_msg.prefix_len);
if !network_manager.is_no_tun() {
state.record_log("正在创建 TUN 虚拟网卡");
network_manager.start_tun().await?;
if network_manager.device_mode().has_device() {
let mode = network_manager.device_mode();
state.record_log(&file_name, format!("正在创建 {} 虚拟网卡", mode));
network_manager.start_device().await?;
state.record_log("创建 TUN 虚拟网卡成功,设置 IP");
state.record_log(&file_name, format!("创建 {} 虚拟网卡成功,设置 IP", mode));
network_manager
.set_network_ip(reg_msg.ip, reg_msg.prefix_len)
.set_device_network_ip(reg_msg.ip, reg_msg.prefix_len)
.await?;
state.record_log("设置 IP 成功");
state.record_log(&file_name, "设置 IP 成功");
// 配置子网路由
if !sub_input.is_empty()
&& let Ok(if_index) = network_manager.tun_if_index().await
&& let Ok(if_index) = network_manager.device_if_index().await
&& let Ok(mut route_manager) = route_manager::RouteManager::new()
{
state.record_log("配置子网路由");
state.record_log(&file_name, "配置子网路由");
for input in &sub_input {
let route =
route_manager::Route::new(input.net.network().into(), input.net.prefix_len())
@@ -598,23 +1023,28 @@ async fn start_vnt_network(
}
}
}
} else {
state.record_log(&file_name, "device_mode=no,不创建虚拟网卡");
}
state.starting_to_running();
state.starting_to_running(&file_name);
// 启动网络管理任务
task_group.spawn(async move {
// 启动成功后记录到自启列表
record_add_running(&file_name).await;
// 启动网络管理任务。
// 注意必须在任务组外等待:等待目标就是这个 task_group
// 若 spawn 进组内会形成自引用等待,网络自行停止时永不返回
let file_name_for_wait = file_name.clone();
tokio::spawn(async move {
network_manager.wait_all_stopped().await;
drop(task_group_guard);
drop(network_manager);
drop(vnt_cleanup_guard);
record_remove_running(&file_name_for_wait).await;
log::info!("Network manager stopped.");
});
// 记录当前配置
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, &file_name).await {
log::warn!("Failed to record current config: {}", e);
}
Ok(())
}
@@ -625,6 +1055,16 @@ fn is_valid_file_name(file_name: &str) -> bool {
&& !file_name.contains('\\')
}
/// 规范化配置文件名:无扩展名时补 .toml;扩展名不是 .toml 则拒绝。
/// list_configs 只列出 *.toml,不强制后缀会保存出列表中不可见的文件
fn normalize_config_file_name(file_name: String) -> Result<String, &'static str> {
match Path::new(&file_name).extension() {
None => Ok(format!("{file_name}.toml")),
Some(ext) if ext == "toml" => Ok(file_name),
Some(_) => Err("Config file name must end with .toml"),
}
}
async fn start_vnt_handler(
State(state): State<HttpAppState>,
Json(req): Json<FileReq>,
@@ -644,16 +1084,42 @@ async fn start_vnt_handler(
}
}
async fn stop_vnt_handler(State(state): State<HttpAppState>) -> Json<ApiResponse<()>> {
if state.status() == VntStatus::Stopped {
async fn stop_vnt_handler(
State(state): State<HttpAppState>,
Json(req): Json<FileReq>,
) -> Json<ApiResponse<()>> {
let Some(task_group_manager) = state.task_group_manager(&req.file_name) else {
return Json(ApiResponse::error("实例不存在"));
};
if state.status(&req.file_name) == VntStatus::Stopped {
return Json(ApiResponse::error("Vnt stopped"));
}
state.task_group_manager.stop();
// 先中断可能处于注册重试循环中的启动任务,再停止任务组
state.abort_start_task(&req.file_name);
task_group_manager.stop();
let _ = fs::write(CURRENT_CONFIG_RECORD, "").await;
record_remove_running(&req.file_name).await;
Json(ApiResponse::success(()))
}
/// 移除已停止的实例条目(清理启动失败的残留卡片)
async fn dismiss_instance_handler(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<()>> {
let mut lock = state.inner.lock();
match lock.instances.get(&req.file_name) {
None => Json(ApiResponse::error("实例不存在")),
Some(inst) if inst.status != VntStatus::Stopped => {
Json(ApiResponse::error("实例正在运行,不能移除"))
}
Some(_) => {
lock.instances.remove(&req.file_name);
Json(ApiResponse::success(()))
}
}
}
async fn restart_vnt_handler(
State(state): State<HttpAppState>,
Json(req): Json<FileReq>,
@@ -668,11 +1134,14 @@ async fn restart_vnt_handler(
}
// 先停止(如果正在运行则停止,否则忽略)
if state.status() != VntStatus::Stopped {
state.task_group_manager.stop();
if state.status(&req.file_name) != VntStatus::Stopped {
state.abort_start_task(&req.file_name);
if let Some(task_group_manager) = state.task_group_manager(&req.file_name) {
task_group_manager.stop();
}
// 等待停止完成
for _ in 0..50 {
if state.status() == VntStatus::Stopped {
if state.status(&req.file_name) == VntStatus::Stopped {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -686,11 +1155,37 @@ async fn restart_vnt_handler(
}
}
async fn get_info(State(state): State<HttpAppState>) -> Json<ApiResponse<HttpAppInfo>> {
let lock = state.inner.lock();
let status = lock.status;
/// 客户端版本号,与组网状态无关,任何时刻都可获取
async fn get_version() -> Json<ApiResponse<String>> {
Json(ApiResponse::success(env!("CARGO_PKG_VERSION").to_string()))
}
let info = if let Some(handler) = lock.vnt.as_ref() {
async fn get_info(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<HttpAppInfo>> {
// 先读当前配置文件(异步),避免持锁跨 await
let current_config: Option<StartConfig> =
match fs::read_to_string(Path::new(CONFIG_DIR).join(&req.file_name)).await {
Ok(content) => toml::from_str(&content).ok(),
Err(_) => None,
};
let lock = state.inner.lock();
let Some(inst) = lock.instances.get(&req.file_name) else {
return Json(ApiResponse::error("实例不存在"));
};
let status = inst.status;
// 与启动时的配置快照对比:文件缺失或解析失败也视为已变化
let config_changed = status != VntStatus::Stopped
&& match (&inst.start_config, &current_config) {
(Some(base), Some(current)) => base != current,
(Some(_), None) => true,
(None, _) => false,
};
let info = if let Some(handler) = inst.vnt.as_ref() {
let api = &handler.api;
let config = api.get_config();
let ips = api.client_ips();
@@ -738,11 +1233,13 @@ async fn get_info(State(state): State<HttpAppState>) -> Json<ApiResponse<HttpApp
compress: config.as_ref().map(|v| v.compress),
encrypt: config.as_ref().map(|v| v.password.is_some()),
rtx: config.as_ref().map(|v| v.rtx),
config_changed,
}
} else {
HttpAppInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
status,
config_changed,
..Default::default()
}
};
@@ -795,7 +1292,13 @@ async fn list_configs() -> Json<ApiResponse<Vec<ConfigSummary>>> {
async fn save_config(Json(req): Json<SaveConfigReq>) -> Json<ApiResponse<()>> {
// 验证配置格式
if let Err(e) = toml::from_str::<StartConfig>(&req.config) {
let parsed = toml::from_str::<StartConfig>(&req.config).and_then(|config| {
config
.reject_legacy_no_tun()
.map(|_| config)
.map_err(serde::de::Error::custom)
});
if let Err(e) = parsed {
log::warn!("Failed to parse configuration: {:?}", e);
return Json(ApiResponse::error(format!("Invalid TOML format: {}", e)));
}
@@ -815,6 +1318,11 @@ async fn save_config(Json(req): Json<SaveConfigReq>) -> Json<ApiResponse<()>> {
return Json(ApiResponse::error("Invalid file name"));
}
let file_name = match normalize_config_file_name(file_name) {
Ok(name) => name,
Err(msg) => return Json(ApiResponse::error(msg)),
};
let target_path = Path::new(CONFIG_DIR).join(&file_name);
match fs::write(&target_path, &req.config).await {
@@ -848,8 +1356,10 @@ async fn delete_config(
return Json(ApiResponse::error("Invalid file name"));
}
{
if let Some(vnt) = &state.inner.lock().vnt
&& vnt.config_file_name == req.file_name
let lock = state.inner.lock();
// 实例存在且有运行内容(已运行或非 Stopped)即视为占用
if let Some(inst) = lock.instances.get(&req.file_name)
&& (inst.vnt.is_some() || inst.status != VntStatus::Stopped)
{
return Json(ApiResponse::error("此配置已被使用,不能删除"));
}
@@ -868,6 +1378,7 @@ async fn delete_config(
}
fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
cfg.reject_legacy_no_tun()?;
let server_addrs: Vec<ProtocolAddress> = cfg
.server
.iter()
@@ -927,18 +1438,20 @@ fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
device_id,
device_name,
tun_name: cfg.tun_name,
outbound_interface: cfg.outbound_interface,
password: cfg.password,
cert_mode,
input: cfg.input,
output: cfg.output,
no_nat: cfg.no_nat,
no_tun: cfg.no_tun,
device_mode: cfg.device_mode,
mtu: cfg.mtu,
port_mapping,
allow_port_mapping: cfg.allow_mapping,
udp_stun,
tcp_stun,
fec: cfg.fec,
tunnel_port: cfg.tunnel_port,
})
}
@@ -966,8 +1479,17 @@ async fn shutdown_signal() {
}
}
async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpClientItem>>> {
let api = state.inner.lock().vnt.as_ref().map(|v| v.api.clone());
async fn get_peers(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<Vec<HttpClientItem>>> {
let api = state
.inner
.lock()
.instances
.get(&req.file_name)
.and_then(|inst| inst.vnt.as_ref())
.map(|v| v.api.clone());
let Some(api) = api else {
return Json(ApiResponse::error("VNT not running"));
@@ -1014,6 +1536,7 @@ async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<Ht
protocol: route.route_key().protocol().to_string(),
metric: route.metric(),
rtt: route.rtt(),
loss_rate: route.loss_rate(),
})
};
@@ -1077,10 +1600,17 @@ async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<Ht
Json(ApiResponse::success(items))
}
async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
async fn get_routes(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
let lock = state.inner.lock();
let Some(handler) = lock.vnt.as_ref() else {
let Some(handler) = lock
.instances
.get(&req.file_name)
.and_then(|inst| inst.vnt.as_ref())
else {
return Json(ApiResponse::error("VNT not running"));
};
@@ -1096,6 +1626,7 @@ async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<H
protocol: v.route_key().protocol().to_string(),
metric: v.metric(),
rtt: v.rtt(),
loss_rate: v.loss_rate(),
})
.collect(),
})
@@ -1103,3 +1634,335 @@ async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<H
Json(ApiResponse::success(items))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_ipc_request_uses_in_process_router() {
let service = VntService {
router: api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
};
let response = service.request("GET", "/api/version", None).await.unwrap();
assert_eq!(response["code"], 0);
assert!(
response["data"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
let response = service.request("GET", "/api/runtime", None).await.unwrap();
assert_eq!(response["code"], 0);
assert_eq!(response["data"], "standalone_web");
let desktop_service = VntService {
router: api_router(new_test_state(), ServiceRuntime::DesktopWeb),
};
let response = desktop_service
.request("GET", "/api/runtime", None)
.await
.unwrap();
assert_eq!(response["code"], 0);
assert_eq!(response["data"], "desktop_web");
}
#[tokio::test]
async fn test_http_api_requires_bearer_token() {
let token = "test-token-with-enough-entropy".to_string();
let app = http_router(
api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
token.clone(),
);
let unauthorized = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/version")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let authorized = app
.oneshot(
axum::http::Request::builder()
.uri("/api/version")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
}
#[test]
fn test_normalize_config_file_name() {
// 无扩展名补 .toml
assert_eq!(
normalize_config_file_name("myconfig".to_string()).unwrap(),
"myconfig.toml"
);
// 已是 .toml 保持不变
assert_eq!(
normalize_config_file_name("a.toml".to_string()).unwrap(),
"a.toml"
);
// 其他扩展名拒绝(list_configs 只列 *.toml,保存了也不可见)
assert!(normalize_config_file_name("a.txt".to_string()).is_err());
assert!(normalize_config_file_name("a.json".to_string()).is_err());
}
#[test]
fn test_resolve_static_path_allows_normal_paths() {
assert_eq!(
resolve_static_path("index.html"),
Some(PathBuf::from("static").join("index.html"))
);
assert_eq!(
resolve_static_path("css/style.css"),
Some(PathBuf::from("static").join("css").join("style.css"))
);
assert_eq!(
resolve_static_path("./index.html"),
Some(PathBuf::from("static").join("index.html"))
);
}
#[test]
fn test_resolve_static_path_rejects_traversal() {
assert!(resolve_static_path("../Cargo.toml").is_none());
assert!(resolve_static_path("a/../../Cargo.toml").is_none());
assert!(resolve_static_path("/etc/passwd").is_none());
assert!(resolve_static_path("..").is_none());
// Windows 下反斜杠也是路径分隔符
#[cfg(windows)]
{
assert!(resolve_static_path("..\\..\\Cargo.toml").is_none());
assert!(resolve_static_path("C:/Windows/win.ini").is_none());
}
}
fn new_test_state() -> HttpAppState {
HttpAppState {
inner: Arc::new(Mutex::new(HttpAppStateInner::default())),
}
}
fn new_test_config() -> StartConfig {
StartConfig {
config_name: None,
server: Vec::new(),
cert_mode: None,
network_code: "test".to_string(),
device_id: Some("device-a".to_string()),
device_name: None,
tun_name: None,
outbound_interface: None,
ip: None,
password: None,
no_punch: false,
compress: false,
rtx: false,
fec: false,
input: Vec::new(),
output: Vec::new(),
no_nat: false,
// 默认无网卡,避免无关用例意外触发 tun_name 冲突
device_mode: DeviceMode::No,
legacy_no_tun: None,
mtu: None,
port_mapping: Vec::new(),
allow_mapping: false,
udp_stun: Vec::new(),
tcp_stun: Vec::new(),
tunnel_port: None,
}
}
#[test]
fn test_device_mode_config_and_legacy_rejection() {
let base = r#"server = ["quic://127.0.0.1:29872"]
network_code = "test"
"#;
let default_cfg: StartConfig = toml::from_str(base).unwrap();
assert_eq!(default_cfg.device_mode, DeviceMode::Tun);
let tap_cfg: StartConfig =
toml::from_str(&format!("{base}device_mode = \"tap\"\n")).unwrap();
assert_eq!(tap_cfg.device_mode, DeviceMode::Tap);
let legacy: StartConfig = toml::from_str(&format!("{base}no_tun = true\n")).unwrap();
assert!(legacy.reject_legacy_no_tun().is_err());
}
/// 两个实例同时处于 Starting 互不影响
#[test]
fn test_two_instances_starting_independent() {
let state = new_test_state();
state.starting("a.toml").unwrap();
state.starting("b.toml").unwrap();
state.record_log("a.toml", "a 的日志");
state.record_log("b.toml", "b 的日志");
assert_eq!(state.status("a.toml"), VntStatus::Starting);
assert_eq!(state.status("b.toml"), VntStatus::Starting);
// a 启动失败停止,b 的状态和日志不受影响
state.record_log_and_stopped("a.toml", "启动失败");
assert_eq!(state.status("a.toml"), VntStatus::Stopped);
assert_eq!(state.status("b.toml"), VntStatus::Starting);
let lock = state.inner.lock();
let a = lock.instances.get("a.toml").unwrap();
assert!(a.start_logs.iter().any(|l| l.contains("启动失败")));
let b = lock.instances.get("b.toml").unwrap();
assert_eq!(b.start_logs.len(), 1);
assert!(b.start_logs[0].contains("b 的日志"));
}
/// 移除已停止实例:Stopped 可移除,Starting 拒绝
#[tokio::test]
async fn test_dismiss_instance() {
let state = new_test_state();
state.starting("a.toml").unwrap();
state.record_log_and_stopped("a.toml", "启动失败");
state.starting("b.toml").unwrap();
// Starting 中的实例不能移除
let resp = dismiss_instance_handler(
State(state.clone()),
Query(FileReq {
file_name: "b.toml".to_string(),
}),
)
.await;
assert_eq!(resp.code, -1);
assert!(state.inner.lock().instances.contains_key("b.toml"));
// 已停止(启动失败残留)的实例可以移除
let resp = dismiss_instance_handler(
State(state.clone()),
Query(FileReq {
file_name: "a.toml".to_string(),
}),
)
.await;
assert_eq!(resp.code, 0);
assert!(!state.inner.lock().instances.contains_key("a.toml"));
// 不存在的实例报错
let resp = dismiss_instance_handler(
State(state.clone()),
Query(FileReq {
file_name: "nope.toml".to_string(),
}),
)
.await;
assert_eq!(resp.code, -1);
}
/// 同一 file_name 重复 starting 报错
#[test]
fn test_duplicate_starting_same_file() {
let state = new_test_state();
state.starting("a.toml").unwrap();
assert!(state.starting("a.toml").is_err());
// 不同 file_name 不受影响
state.starting("b.toml").unwrap();
}
/// device_id 相同(含双方都为 None)且同服务器同组网时冲突;
/// 不同服务器或不同 network_code 时允许相同 device_id
#[test]
fn test_conflict_same_device_id() {
let running = new_test_config();
// 相同 device_id(双方 server 均为空,视为同范围)
let new = new_test_config();
assert!(check_config_conflict(&new, &[&running]).is_err());
// 双方都不指定 device_id(缺省会用同一 machine_uid)也算冲突
let mut a = new_test_config();
a.device_id = None;
let mut b = new_test_config();
b.device_id = None;
assert!(check_config_conflict(&b, &[&a]).is_err());
// 不同 device_id 不冲突
let mut c = new_test_config();
c.device_id = Some("device-c".to_string());
assert!(check_config_conflict(&c, &[&running]).is_ok());
// 相同 device_id 但 network_code 不同 → 不冲突
let mut d = new_test_config();
d.network_code = "other-net".to_string();
assert!(check_config_conflict(&d, &[&running]).is_ok());
// 相同 device_id 相同 network_code 但服务器不同 → 不冲突
let mut e_running = new_test_config();
e_running.server = vec!["server1:29870".to_string()];
let mut e = new_test_config();
e.server = vec!["server2:29870".to_string()];
assert!(check_config_conflict(&e, &[&e_running]).is_ok());
// 相同 device_id 相同 network_code 且服务器有交集 → 冲突
let mut f = new_test_config();
f.server = vec!["server1:29870".to_string(), "server3:29870".to_string()];
assert!(check_config_conflict(&f, &[&e_running]).is_err());
}
/// tunnel_port 都为 Some 且相等时冲突
#[test]
fn test_conflict_same_tunnel_port() {
let mut running = new_test_config();
running.device_id = Some("d1".to_string());
running.tunnel_port = Some(12345);
let mut new = new_test_config();
new.device_id = Some("d2".to_string());
new.tunnel_port = Some(12345);
assert!(check_config_conflict(&new, &[&running]).is_err());
// 一方未指定不冲突
let mut new_none = new_test_config();
new_none.device_id = Some("d2".to_string());
assert!(check_config_conflict(&new_none, &[&running]).is_ok());
// 端口不同不冲突
let mut new_other = new_test_config();
new_other.device_id = Some("d2".to_string());
new_other.tunnel_port = Some(23456);
assert!(check_config_conflict(&new_other, &[&running]).is_ok());
}
/// Starting 状态下执行停止:必须中断注册重试循环并迁移到 Stopped。
/// 复现 bug 场景——服务器不可达时启动任务陷在无限重试里,
/// 不中断启动任务则状态永远卡在 Starting。
#[tokio::test]
async fn test_stop_during_starting() {
let state = new_test_state();
let file_name = "a.toml";
state.starting(file_name).unwrap();
// 模拟启动任务:注册一直失败、5 秒重试的无限循环
let state_clone = state.clone();
let file_name_owned = file_name.to_string();
let on_error_guard = defer(move || {
state_clone.starting_to_stopped(&file_name_owned);
});
let handle = tokio::spawn(async move {
let _on_error_guard = on_error_guard;
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
});
state.set_start_handle(file_name, handle);
assert_eq!(state.status(file_name), VntStatus::Starting);
state.abort_start_task(file_name);
// abort 生效后 defer 触发,状态应迁移到 Stopped
for _ in 0..100 {
if state.status(file_name) == VntStatus::Stopped {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(state.status(file_name), VntStatus::Stopped);
}
}
-2884
View File
@@ -1,2884 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>VNT Dashboard</title>
<link rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='0' y='0' width='64' height='64' rx='16' fill='%230f172a'/%3E%3Cpath d='M16 20 L32 48 L48 20' fill='none' stroke='%233b82f6' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='6' fill='%2360a5fa'/%3E%3Ccircle cx='48' cy='20' r='6' fill='%2360a5fa'/%3E%3Ccircle cx='32' cy='48' r='6' fill='%2322c55e'/%3E%3C/svg%3E"
type="image/svg+xml">
<script src="tailwindcss3.4.17.js.gz"></script>
<script src="vue.global.prod.js.gz"></script>
<script src="vue-router.global.prod.js.gz"></script>
<style>
[v-cloak] {
display: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
body {
background-color: #0f172a;
color: #e2e8f0;
}
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.input-dark {
background-color: #1e293b;
border-color: #334155;
color: white;
}
.input-dark:focus {
border-color: #3b82f6;
outline: none;
ring: 2px;
}
/* Tooltip CSS (保留用于表格内的静态 tooltip) */
.tooltip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 140px;
background-color: rgba(0, 0, 0, 0.9);
color: #fff;
text-align: center;
border-radius: 6px;
padding: 6px 4px;
position: absolute;
z-index: 10;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
font-size: 12px;
pointer-events: none;
border: 1px solid #475569;
}
.tooltip .tooltip-text::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
/* 路由切换动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(71, 85, 105, 0.8);
border-radius: 4px;
transition: background 0.2s;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
}
/* Firefox 滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(71, 85, 105, 0.8) rgba(15, 23, 42, 0.5);
}
</style>
</head>
<body class="h-screen overflow-hidden flex flex-col font-sans">
<div id="app" v-cloak class="flex h-full">
<!-- 侧边栏 -->
<aside
class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"
>
<div
class="p-6 flex items-center justify-center border-b border-slate-800"
>
<h1 class="text-2xl font-bold text-blue-500 tracking-wider">
VNT<span class="text-slate-400 text-sm ml-2">Web</span>
</h1>
</div>
<nav class="flex-1 p-4 space-y-2">
<!-- 使用 router-link 和 v-slot 自定义渲染按钮 -->
<router-link
to="/general"
custom
v-slot="{ navigate, isActive }"
>
<button
@click="navigate"
:class="navClass(isActive)"
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
>
<svg
class="w-5 h-5 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
></path>
</svg>
通用
</button>
</router-link>
<router-link
to="/config"
custom
v-slot="{ navigate, isActive }"
>
<button
@click="navigate"
:class="navClass(isActive)"
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
>
<svg
class="w-5 h-5 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
配置
</button>
</router-link>
<router-link
to="/peers"
custom
v-slot="{ navigate, isActive }"
>
<button
@click="navigate"
:class="navClass(isActive)"
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
>
<svg
class="w-5 h-5 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
设备列表
</button>
</router-link>
<router-link
to="/routes"
custom
v-slot="{ navigate, isActive }"
>
<button
@click="navigate"
:class="navClass(isActive)"
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
>
<svg
class="w-5 h-5 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 01-.806-.98l-3.747-1.874O12 7m3 13V7m-3 0l3 3"
></path>
</svg>
路由
</button>
</router-link>
</nav>
<div class="p-4 text-xs text-slate-500 text-center">
Client: v{{ info.version }}
</div>
</aside>
<main
class="flex-1 flex flex-col bg-slate-900/50 relative overflow-hidden"
>
<!-- Header -->
<header
class="h-16 glass-panel border-b border-slate-700 flex items-center justify-between px-8 z-10 shrink-0"
>
<div class="flex items-center space-x-6">
<div
class="flex items-center space-x-2 bg-slate-800 rounded-full px-3 py-1 border border-slate-700"
>
<span
class="w-2.5 h-2.5 rounded-full"
:class="info.status === 'running' ? 'bg-green-500 animate-pulse' : (info.status === 'starting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')"
></span>
<span class="text-sm font-medium"
>{{ info.status === 'running' ? '已运行' :
(info.status === 'starting' ? '启动中...' :
'未启动') }}</span
>
</div>
<div
v-if="info.status === 'running'"
class="flex items-center space-x-2 bg-slate-800 rounded-full px-3 py-1 border border-slate-700"
:title="serverStatusText"
>
<svg
class="w-4 h-4"
:class="isServerConnected ? 'text-green-400' : 'text-red-400'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
></path>
</svg>
<span
class="text-sm font-medium"
:class="isServerConnected ? 'text-green-400' : 'text-red-400'"
>服务器: {{ isServerConnected ? '已连接' :
'未连接' }}</span
>
</div>
<div
v-if="info.ip"
class="flex items-center space-x-2 text-slate-300"
>
<span
class="font-mono text-blue-400 font-bold bg-blue-900/20 px-2 py-0.5 rounded"
>{{ info.ip }}</span
>
</div>
</div>
<div class="flex items-center space-x-2 text-slate-400">
<span class="text-sm">设备:</span>
<span class="font-bold text-white"
>{{ info.name || '' }}</span
>
<span
class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-500"
title="Device ID"
>{{ info.device_id.substring(0, 8) }}...</span
>
</div>
</header>
<!-- Router View with Transition -->
<div class="flex-1 overflow-auto scrollbar-hide p-8 relative">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component"/>
</transition>
</router-view>
</div>
<!-- 启动日志弹窗 (Global) -->
<div
v-if="showStartLog"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
>
<div
class="bg-slate-900/95 border border-slate-700 rounded-xl w-full max-w-2xl flex flex-col shadow-2xl overflow-hidden"
>
<div
class="px-6 py-4 border-b border-slate-700 flex justify-between items-center bg-slate-800/50"
>
<div class="flex items-center space-x-3">
<div
v-if="startStatus === 'starting'"
class="w-3 h-3 bg-blue-500 rounded-full animate-ping"
></div>
<div
v-else-if="startStatus === 'running'"
class="w-3 h-3 bg-green-500 rounded-full"
></div>
<div
v-else
class="w-3 h-3 bg-red-500 rounded-full"
></div>
<h3 class="text-lg font-bold text-white">
{{ startStatus === 'starting' ?
'正在启动组网...' : (startStatus ===
'running' ? '启动成功' : '启动失败') }}
</h3>
</div>
<span
class="text-xs font-mono text-slate-500 uppercase tracking-widest"
>{{ startStatus }}</span
>
</div>
<div
ref="logContainer"
class="flex-1 p-6 h-80 overflow-y-auto scrollbar-hide font-mono text-sm space-y-2 bg-black/20"
>
<div
v-for="(log, idx) in startLogs"
:key="idx"
class="flex space-x-3 animate-in fade-in slide-in-from-left-2"
>
<span class="text-blue-500 shrink-0">>>></span>
<span class="text-slate-300 break-all"
>{{ log }}</span
>
</div>
<div
v-if="startStatus === 'starting'"
class="text-blue-400 animate-pulse italic mt-4"
>
等待后续步骤...
</div>
<div
v-if="startStatus === 'stopped' && startLogs.length > 0"
class="p-3 bg-red-900/20 border border-red-900/50 rounded-lg text-red-400 mt-4"
>
<strong>启动失败:</strong>
请检查配置或网络连接。
</div>
</div>
<div
class="p-4 border-t border-slate-700 flex justify-end space-x-3 bg-slate-800/50"
>
<button
v-if="startStatus === 'starting'"
@click="cancelStart"
class="px-6 py-2 bg-slate-700 hover:bg-red-600 text-white rounded-lg transition-colors font-medium"
>
取消组网
</button>
<button
v-if="startStatus === 'stopped' || startStatus === 'running'"
@click="showStartLog = false"
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
>
关闭窗口
</button>
</div>
</div>
</div>
<!-- Tooltip (Global Teleport) -->
<teleport to="body">
<div
v-if="tooltipState.show"
:style="{ top: tooltipState.y + 'px', left: tooltipState.x + 'px' }"
class="fixed z-[9999] transform -translate-x-1/2 mt-1"
@mouseenter="onTooltipEnter"
@mouseleave="onTooltipLeave"
>
<div
class="w-auto min-w-[260px] max-w-[320px] text-left p-4 bg-slate-800 border border-slate-600 shadow-2xl rounded-lg text-sm text-slate-200"
>
<div
class="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-slate-800 border-t border-l border-slate-600 transform rotate-45"
></div>
<div
class="flex justify-between items-center border-b border-slate-600 pb-2 mb-2 relative z-10"
>
<span
class="text-slate-400 text-xs uppercase font-bold"
>NAT Type</span
>
<span
class="text-green-400 font-bold bg-green-900/30 px-2 py-0.5 rounded text-xs border border-green-800"
>{{ tooltipState.info.nat_type }}</span
>
</div>
<div
v-if="tooltipState.info.public_ips && tooltipState.info.public_ips.length > 0"
class="mb-3 relative z-10"
>
<span class="text-slate-400 text-xs block mb-1"
>Public IPv4:</span
>
<div class="flex flex-wrap gap-1">
<span
v-for="pip in tooltipState.info.public_ips"
:key="pip"
class="text-xs bg-slate-700 text-slate-200 px-1.5 py-0.5 rounded border border-slate-600"
>{{ pip }}</span
>
</div>
</div>
<div
v-if="tooltipState.info.ipv6"
class="relative z-10"
>
<span class="text-slate-400 text-xs block mb-1"
>IPv6:</span
>
<div
class="text-slate-200 text-xs break-all whitespace-normal leading-relaxed bg-slate-900/50 p-1.5 rounded border border-slate-700/50"
>
{{ tooltipState.info.ipv6 }}
</div>
</div>
</div>
</div>
</teleport>
</main>
</div>
<!-- ================= TEMPLATES ================= -->
<!-- 1. General (通用) -->
<template id="tpl-general">
<div class="space-y-6 max-w-5xl mx-auto">
<div class="glass-panel rounded-xl p-6 shadow-lg">
<h2
class="text-xl font-bold mb-6 text-white border-l-4 border-blue-500 pl-3"
>
运行控制
</h2>
<div class="flex items-end space-x-4">
<div class="flex-1">
<label
class="block text-sm font-medium text-slate-400 mb-2"
>选择配置</label
>
<select
v-model="localSelectedConfig"
:disabled="info.status === 'starting'"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="" disabled>请选择配置...</option>
<option
v-for="cfg in configList"
:key="cfg.file_name"
:value="cfg.file_name"
>
{{ cfg.config_name || cfg.file_name }}
</option>
</select>
</div>
<button
v-if="info.status === 'running'"
@click="handleRestart"
:disabled="loading || !localSelectedConfig"
class="px-6 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed bg-blue-500 hover:bg-blue-600"
>
<span v-if="loading" class="mr-2 animate-spin"></span>
重启
</button>
<button
@click="handleToggle"
:disabled="loading || (!localSelectedConfig && info.status !== 'running' && info.status !== 'starting')"
:class="(info.status === 'running' || info.status === 'starting') ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
class="px-8 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="loading" class="mr-2 animate-spin"
>⟳</span
>
{{ (info.status === 'running' || info.status ===
'starting') ? '停止运行' : '启动运行' }}
</button>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-blue-500"
>
<span class="text-slate-400 text-sm mb-1"
>在线设备</span
>
<span class="text-3xl font-bold text-blue-400"
>{{ info.online_client_num }}</span
>
</div>
<div
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-green-500"
>
<span class="text-slate-400 text-sm mb-1"
>直连设备</span
>
<span class="text-3xl font-bold text-green-400"
>{{ info.direct_client_num }}</span
>
</div>
<div
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-slate-500"
>
<span class="text-slate-400 text-sm mb-1"
>离线设备</span
>
<span class="text-3xl font-bold text-slate-400"
>{{ info.offline_client_num }}</span
>
</div>
<div
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-yellow-500"
>
<span class="text-slate-400 text-sm mb-1"
>当前配置</span
>
<span
class="text-lg font-medium text-yellow-400 truncate w-full text-center px-2"
:title="info.current_config_file"
>
{{ info.current_config_name || '-' }}
</span>
</div>
</div>
<div class="glass-panel rounded-xl p-6 shadow-lg">
<h2 class="text-lg font-bold mb-4 text-white">网络详情</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div
class="flex justify-between border-b border-slate-700 pb-2"
>
<span class="text-slate-400">虚拟 IP / 掩码</span>
<span class="font-mono text-white"
>{{ info.ip || '-' }} / {{ info.prefix_len ||
'-' }}</span
>
</div>
<div
class="flex justify-between border-b border-slate-700 pb-2"
>
<span class="text-slate-400">网关</span>
<span class="font-mono text-white"
>{{ info.gateway || '-' }}</span
>
</div>
<div
class="flex justify-between border-b border-slate-700 pb-2"
>
<span class="text-slate-400">网络编号</span>
<span class="font-mono text-white"
>{{ info.network_code || '-' }}</span
>
</div>
<div
class="flex justify-between border-b border-slate-700 pb-2"
>
<span class="text-slate-400">MTU</span>
<span class="font-mono text-white"
>{{ info.mtu || '' }}</span
>
</div>
<div
class="flex justify-between border-b border-slate-700 pb-2"
>
<span class="text-slate-400">NAT 类型</span>
<span class="font-mono text-blue-300"
>{{ info.nat_type || 'Unknown' }}</span
>
</div>
<div
class="flex justify-between border-b border-slate-700 pb-2"
>
<span class="text-slate-400">Public IPv6</span>
<span
class="font-mono text-white truncate max-w-[200px]"
:title="info.public_ipv6"
>{{ info.public_ipv6 || '-' }}</span
>
</div>
</div>
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
<span class="w-2 h-2 rounded-full" :class="info.encrypt ? 'bg-green-500' : 'bg-slate-600'"></span>
<span class="text-sm text-slate-300">加密</span>
</div>
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
<span class="w-2 h-2 rounded-full" :class="info.compress ? 'bg-green-500' : 'bg-slate-600'"></span>
<span class="text-sm text-slate-300">压缩</span>
</div>
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
<span class="w-2 h-2 rounded-full" :class="info.fec ? 'bg-green-500' : 'bg-slate-600'"></span>
<span class="text-sm text-slate-300">FEC纠错</span>
</div>
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
<span class="w-2 h-2 rounded-full" :class="info.rtx ? 'bg-green-500' : 'bg-slate-600'"></span>
<span class="text-sm text-slate-300">QUIC传输</span>
</div>
</div>
<div class="mt-4">
<span class="text-slate-400 text-sm block mb-2"
>Public IPv4s</span
>
<div class="flex flex-wrap gap-2">
<span
v-for="pip in info.public_ipv4s"
:key="pip"
class="px-2 py-1 bg-slate-800 rounded text-xs font-mono text-green-300 border border-slate-600"
>{{ pip }}</span
>
<span
v-if="!info.public_ipv4s || info.public_ipv4s.length === 0"
class="text-slate-600 text-xs"
>无</span
>
</div>
</div>
</div>
<div class="glass-panel rounded-xl p-6 shadow-lg">
<h2 class="text-lg font-bold mb-4 text-white">
服务器连接列表
</h2>
<div
class="overflow-auto max-h-[400px] custom-scrollbar rounded-lg border border-slate-700"
>
<table class="min-w-full divide-y divide-slate-700">
<thead class="bg-slate-800">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
地址
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
状态
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
延迟
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
版本
</th>
</tr>
</thead>
<tbody
class="divide-y divide-slate-700 bg-slate-900/30"
>
<tr
v-for="(server, idx) in info.server_info"
:key="idx"
>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-300 font-mono"
>
{{ server.server }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
:class="server.connected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
>
{{ server.connected ? '已连接' :
'未连接' }}
</span>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
>
{{ server.server_rtt ? server.server_rtt
+ ' ms' : '-' }}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
>
{{ server.server_version || '-' }}
</td>
</tr>
<tr
v-if="!info.server_info || info.server_info.length === 0"
>
<td
colspan="4"
class="px-6 py-4 text-center text-slate-500"
>
暂无数据
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<!-- 2. Config (配置) -->
<template id="tpl-config">
<div class="space-y-6 max-w-5xl mx-auto h-full flex flex-col">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold text-white">配置管理</h2>
<button
@click="openEditor(null)"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center transition-colors"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
></path>
</svg>
新建配置
</button>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<div
v-for="cfg in configList"
:key="cfg.file_name"
:class="{'ring-2 ring-green-500 bg-slate-800/80': info.current_config_file === cfg.file_name, 'bg-slate-800/40 hover:bg-slate-800/60': info.current_config_file !== cfg.file_name}"
class="rounded-xl p-5 border border-slate-700 transition-all cursor-pointer group relative overflow-hidden flex flex-col justify-between min-h-[140px]"
@click="openEditor(cfg.file_name)"
>
<div
v-if="info.current_config_file === cfg.file_name"
class="absolute top-0 right-0 bg-green-500 text-white text-xs px-2 py-1 rounded-bl"
>
Running
</div>
<div class="flex items-start">
<div
class="p-2 rounded bg-blue-500/10 text-blue-400 mr-3 mt-1"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
></path>
</svg>
</div>
<div class="overflow-hidden">
<h3
class="font-bold text-lg text-white truncate"
:title="cfg.config_name"
>
{{ cfg.config_name || 'Unnamed' }}
</h3>
<p
class="text-xs text-slate-500 font-mono truncate"
:title="cfg.file_name"
>
{{ cfg.file_name }}
</p>
</div>
</div>
<div
class="mt-4 flex justify-end opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
@click.stop="openEditor(cfg.file_name)"
class="text-blue-400 hover:text-blue-300 text-sm mr-4"
>
编辑
</button>
<button
@click.stop="deleteConfig(cfg.file_name)"
class="text-red-400 hover:text-red-300 text-sm"
>
删除
</button>
</div>
</div>
</div>
<!-- 编辑器 Modal -->
<div
v-if="showEditor"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
>
<div
class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-6xl h-[85vh] flex flex-col shadow-2xl"
>
<!-- Header -->
<div class="flex justify-between items-center p-4 border-b border-slate-700 bg-slate-800/50">
<h3 class="text-lg font-bold text-white">
{{ editorMode === 'new' ? '新建配置' : '编辑配置' }}
</h3>
<div class="flex items-center space-x-4">
<!-- 模式切换按钮 -->
<div class="flex bg-slate-800 rounded-lg p-1 border border-slate-600">
<button
@click="switchToFormMode"
:class="editMode === 'form' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'"
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
>
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
表单模式
</button>
<button
@click="switchToTomlMode"
:class="editMode === 'toml' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'"
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
>
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
TOML模式
</button>
</div>
<div class="text-sm text-slate-500 font-mono" v-if="editorFileName">
{{ editorFileName }}
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-hidden">
<!-- 表单模式 -->
<div v-show="editMode === 'form'" class="h-full overflow-y-auto scrollbar-hide p-6">
<div class="max-w-4xl mx-auto space-y-6">
<!-- 基础配置 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-blue-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
基础配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">配置名称</label>
<input v-model="formData.config_name" type="text" placeholder="例如: 我的VPN配置"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
网络编号 <span class="text-red-400">*</span>
</label>
<input v-model="formData.network_code" type="text" placeholder="例如: my_network" required
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-slate-300 mb-2">
服务器地址 <span class="text-red-400">*</span>
<span class="text-xs text-slate-500 ml-2">支持 quic:// tcp:// wss:// dynamic://</span>
</label>
<div class="space-y-2">
<div v-for="(server, idx) in formData.server" :key="idx" class="flex space-x-2">
<input v-model="formData.server[idx]" type="text" placeholder="例如: quic://1.2.3.4:29872"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button @click="formData.server.splice(idx, 1)" v-if="formData.server.length > 1"
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
<button @click="formData.server.push('')"
class="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 rounded-lg transition-colors text-sm flex items-center justify-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
添加服务器
</button>
</div>
</div>
</div>
<!-- 网络设置 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-green-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
网络设置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
自定义虚拟IP
<span class="text-xs text-slate-500 ml-1">(可选)</span>
</label>
<input v-model="formData.ip" type="text" placeholder="例如: 10.26.0.2"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">MTU</label>
<input v-model.number="formData.mtu" type="number" placeholder="1380"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
</div>
</div>
<!-- 传输优化 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-purple-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
传输优化
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-white">QUIC传输优化</div>
<div class="text-xs text-slate-400 mt-0.5">重传丢包</div>
</div>
<input v-model="formData.rtx" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
</label>
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-white">FEC前向纠错</div>
<div class="text-xs text-slate-400 mt-0.5">损失部分带宽提升稳定性</div>
</div>
<input v-model="formData.fec" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
</label>
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-white">LZ4压缩</div>
<div class="text-xs text-slate-400 mt-0.5">减少传输数据量</div>
</div>
<input v-model="formData.compress" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
</label>
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-white">关闭P2P打洞</div>
<div class="text-xs text-slate-400 mt-0.5">仅通过服务器中转</div>
</div>
<input v-model="formData.no_punch" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
</label>
</div>
</div>
<!-- 安全配置 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-red-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
安全配置
</h4>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">组网加密密码(连接公共服务器时建议填写,同一组网密码需要相同)</label>
<input v-model="formData.password" type="password" placeholder="留空则不加密"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">服务端证书校验模式</label>
<select v-model="formData.cert_mode"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="skip">跳过验证 (默认)</option>
<option value="standard">系统证书验证</option>
<option value="finger">证书指纹验证</option>
</select>
</div>
</div>
<div v-if="formData.cert_mode === 'finger'" class="animate-in fade-in slide-in-from-top-2">
<label class="block text-sm font-medium text-slate-300 mb-2">
证书指纹
<span class="text-xs text-slate-500 ml-1">(服务端启动时日志会输出指纹)</span>
</label>
<input v-model="formData.fingerprint" type="text" placeholder="例如: 3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm">
</div>
</div>
</div>
<!-- NAT与路由 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-yellow-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 9m0 0V7m0 2v6"></path>
</svg>
NAT与路由 (点对网)
</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
入栈网段
<span class="text-xs text-slate-500 ml-1">格式: CIDR,目标IP</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.input" :key="idx" class="flex space-x-2">
<input v-model="formData.input[idx]" type="text" placeholder="例如: 192.168.0.0/24,10.26.0.2"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<button @click="formData.input.splice(idx, 1)"
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<button @click="formData.input.push('')"
class="w-full px-3 py-1.5 bg-yellow-600/20 hover:bg-yellow-600/40 text-yellow-400 rounded-lg transition-colors text-sm">
+ 添加入栈网段
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
出栈网段
<span class="text-xs text-slate-500 ml-1">格式: CIDR (允许转发的网段)</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.output" :key="idx" class="flex space-x-2">
<input v-model="formData.output[idx]" type="text" placeholder="例如: 0.0.0.0/0"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<button @click="formData.output.splice(idx, 1)"
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<button @click="formData.output.push('')"
class="w-full px-3 py-1.5 bg-yellow-600/20 hover:bg-yellow-600/40 text-yellow-400 rounded-lg transition-colors text-sm">
+ 添加出栈网段
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-white">关闭内置NAT</div>
<div class="text-xs text-slate-400 mt-0.5">使用系统网卡转发</div>
</div>
<input v-model="formData.no_nat" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
</label>
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-white">关闭TUN网卡</div>
<div class="text-xs text-slate-400 mt-0.5">仅作流量出口或端口映射</div>
</div>
<input v-model="formData.no_tun" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
</label>
</div>
</div>
</div>
<!-- 端口映射 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-orange-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
端口映射
</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
映射规则
<span class="text-xs text-slate-500 ml-1">格式: 协议://监听地址-虚拟IP-目标地址</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.port_mapping" :key="idx" class="flex space-x-2">
<input v-model="formData.port_mapping[idx]" type="text" placeholder="例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm font-mono">
<button @click="formData.port_mapping.splice(idx, 1)"
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<button @click="formData.port_mapping.push('')"
class="w-full px-3 py-1.5 bg-orange-600/20 hover:bg-orange-600/40 text-orange-400 rounded-lg transition-colors text-sm">
+ 添加映射规则
</button>
</div>
</div>
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-white">允许作为映射出口</div>
<div class="text-xs text-slate-400 mt-0.5">允许其他设备使用本机作跳板</div>
</div>
<input v-model="formData.allow_mapping" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
</label>
</div>
</div>
<!-- 设备配置 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-cyan-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
设备配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">设备名称</label>
<input v-model="formData.device_name" type="text" placeholder="默认为主机名"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">设备ID</label>
<input v-model="formData.device_id" type="text" placeholder="自动生成"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">虚拟网卡名</label>
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
</div>
</div>
<!-- STUN配置 -->
<div class="glass-panel rounded-lg p-5 border border-slate-700">
<h4 class="text-md font-bold text-pink-400 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
STUN配置 (高级)
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">UDP STUN服务器</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.udp_stun" :key="idx" class="flex space-x-2">
<input v-model="formData.udp_stun[idx]" type="text" placeholder="例如: stun.l.google.com:19302"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<button @click="formData.udp_stun.splice(idx, 1)"
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<button @click="formData.udp_stun.push('')"
class="w-full px-3 py-1.5 bg-pink-600/20 hover:bg-pink-600/40 text-pink-400 rounded-lg transition-colors text-sm">
+ 添加UDP STUN
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">TCP STUN服务器</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.tcp_stun" :key="idx" class="flex space-x-2">
<input v-model="formData.tcp_stun[idx]" type="text" placeholder="例如: stun.nextcloud.com:443"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<button @click="formData.tcp_stun.splice(idx, 1)"
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<button @click="formData.tcp_stun.push('')"
class="w-full px-3 py-1.5 bg-pink-600/20 hover:bg-pink-600/40 text-pink-400 rounded-lg transition-colors text-sm">
+ 添加TCP STUN
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- TOML模式 -->
<div v-show="editMode === 'toml'" class="h-full">
<textarea
v-model="editorContent"
class="w-full h-full bg-[#1e1e1e] text-[#d4d4d4] font-mono p-4 resize-none focus:outline-none text-sm"
spellcheck="false"
placeholder="# 在此处输入 TOML 配置..."
></textarea>
</div>
</div>
<!-- Footer -->
<div class="p-4 border-t border-slate-700 flex justify-between items-center bg-slate-800/50">
<div class="text-xs text-slate-500">
<span v-if="editMode === 'form'">填写完成后保存即可生成配置文件</span>
<span v-else>* 请使用标准 TOML 格式</span>
</div>
<div class="space-x-3">
<button
@click="showEditor = false"
class="px-4 py-2 rounded text-slate-300 hover:text-white transition-colors"
>
取消
</button>
<button
@click="saveConfig"
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded font-medium shadow-lg transition-colors"
>
保存配置
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 3. Peers (设备列表) -->
<template id="tpl-peers">
<div class="max-w-7xl mx-auto">
<div class="glass-panel rounded-xl shadow-lg overflow-hidden">
<div
class="px-6 py-4 border-b border-slate-700 flex justify-between items-center"
>
<h2 class="text-xl font-bold text-white">设备列表</h2>
<div class="flex space-x-4 text-sm text-slate-400">
<span
>Online:
<span class="text-white"
>{{ peers.filter(p => p.online).length
}}</span
></span
>
<span
>Total:
<span class="text-white"
>{{ peers.length }}</span
></span
>
</div>
</div>
<div class="overflow-auto max-h-[600px] custom-scrollbar">
<table class="min-w-full divide-y divide-slate-700">
<thead class="bg-slate-800">
<tr>
<th class="w-8 px-2 py-3"></th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
IP地址
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
名称
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
版本
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
状态
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
模式
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
延迟
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
丢包率
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
流量
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
>
最后在线
</th>
</tr>
</thead>
<tbody
class="divide-y divide-slate-700 bg-slate-900/30"
>
<template v-for="peer in peers" :key="peer.ip">
<tr class="hover:bg-slate-800/50 transition-colors">
<td class="px-2 py-4 text-center cursor-pointer select-none" @click="toggleExpand(peer.ip)">
<svg class="w-4 h-4 text-slate-500 transition-transform duration-200 inline-block" :class="{ 'rotate-90': expandedPeers[peer.ip] }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</td>
<td
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-blue-300"
>
<span
class="border-b border-dotted border-blue-500/50 pb-0.5 cursor-help"
@mouseenter="showPeerTooltip($event, peer)"
@mouseleave="hidePeerTooltip"
>
{{ peer.ip }}
</span>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-300"
>
{{ peer.name || '-' }}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-xs text-slate-500"
>
{{ peer.version || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
<span
:class="peer.online ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400'"
class="px-2 py-0.5 text-xs rounded-full font-medium"
>{{ peer.online ? 'Online' : 'Offline' }}</span>
<!-- 加密状态图标 -->
<div v-if="peer.online && peer.key_equal === 1" class="tooltip">
<svg
class="w-4 h-4 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
<span class="tooltip-text">双方加密传输</span>
</div>
<div v-else-if="peer.online && peer.key_equal === 2" class="tooltip">
<svg
class="w-4 h-4 text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
></path>
</svg>
<span class="tooltip-text">双方未加密</span>
</div>
<div v-else-if="peer.online && [3,4,5].includes(peer.key_equal)" class="tooltip cursor-help group">
<svg
class="w-4 h-4 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span class="tooltip-text">{{ peer.key_equal === 3 ? '己方加密对方未加密' : peer.key_equal === 4 ? '己方未加密对方加密' : peer.key_equal === 5 ? '密钥不一致' : '未知错误' }}</span>
</div>
</div>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm"
>
<span
v-if="peer.online && peer.route"
:class="getRouteModeClass(peer.route)"
>{{ getRouteModeText(peer.route) }}</span
>
<span v-else-if="peer.online" class="text-yellow-400">服务器中继</span>
<span v-else class="text-slate-600"
>-</span
>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
>
{{ peer.route ? peer.route.rtt + ' ms' : '-' }}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm"
>
<span v-if="peer.packet_loss"
:class="peer.packet_loss.loss_rate > 10 ? 'text-red-400' : peer.packet_loss.loss_rate > 5 ? 'text-yellow-400' : 'text-green-400'"
:title="'Sent: ' + peer.packet_loss.sent + ', Received: ' + peer.packet_loss.received"
>{{ peer.packet_loss.loss_rate.toFixed(1) }}%</span>
<span v-else class="text-slate-600">-</span>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-xs"
>
<div v-if="peer.traffic" class="leading-relaxed">
<div class="text-green-400">↑ {{ formatBytes(peer.traffic.tx_bytes) }} ({{ formatSpeed(peer.traffic.tx_speed) }})</div>
<div class="text-blue-400">↓ {{ formatBytes(peer.traffic.rx_bytes) }} ({{ formatSpeed(peer.traffic.rx_speed) }})</div>
</div>
<span v-else class="text-slate-600">-</span>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-500 font-mono text-xs"
>
{{ formatTime(peer.last_connected_time)
}}
</td>
</tr>
<tr v-if="expandedPeers[peer.ip]">
<td :colspan="10" class="p-0">
<div class="px-4 py-3 bg-slate-950/60 border-t border-slate-700/50">
<div class="flex items-center space-x-4 mb-2 text-xs text-slate-400">
<span class="flex items-center"><span class="inline-block w-3 h-0.5 bg-green-400 mr-1"></span>上传速度</span>
<span class="flex items-center"><span class="inline-block w-3 h-0.5 bg-blue-400 mr-1"></span>下载速度</span>
<span class="ml-auto" :id="'chart-max-' + peer.ip.replaceAll('.', '-')"></span>
</div>
<canvas :id="'chart-' + peer.ip.replaceAll('.', '-')" style="width:100%;height:150px;display:block;" class="rounded"></canvas>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<!-- 4. Routes (路由) -->
<template id="tpl-routes">
<div class="max-w-6xl mx-auto">
<div class="glass-panel rounded-xl shadow-lg overflow-hidden">
<div class="px-6 py-4 border-b border-slate-700">
<h2 class="text-xl font-bold text-white">路由表</h2>
</div>
<div class="overflow-auto max-h-[600px] custom-scrollbar">
<table class="min-w-full divide-y divide-slate-700">
<thead class="bg-slate-800">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
目标节点IP
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
目标网络
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
跳数
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
>
延迟
</th>
</tr>
</thead>
<tbody
class="divide-y divide-slate-700 bg-slate-900/30"
>
<template v-for="item in routes" :key="item.ip">
<tr
v-for="(route, rIdx) in item.routes"
:key="rIdx"
class="hover:bg-slate-800/50"
>
<td
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-blue-300"
v-if="rIdx===0"
:rowspan="item.routes.length"
>
{{ item.ip }}
</td>
<td
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-yellow-300"
>
{{ route.addr }}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
>
{{ route.metric }}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
>
{{ route.rtt }} ms
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
const {
createApp,
ref,
reactive,
onMounted,
onUnmounted,
computed,
watch,
nextTick,
inject,
provide,
} = Vue;
const {createRouter, createWebHashHistory} = VueRouter;
const API_BASE = "";
// 格式化时间工具
const formatTime = (timestamp) => {
if (!timestamp) return "-";
const date = new Date(timestamp * 1000);
const pad = (n) => (n < 10 ? "0" + n : n);
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
// 格式化字节数
const formatBytes = (bytes) => {
if (bytes === 0 || bytes === undefined || bytes === null) return "0B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
};
// 格式化速度(字节/秒)
const formatSpeed = (bytesPerSecond) => {
if (bytesPerSecond === 0 || bytesPerSecond === undefined || bytesPerSecond === null) return "0B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let i = 0;
let value = bytesPerSecond;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
};
// --- 组件定义 ---
const GeneralView = {
template: "#tpl-general",
setup() {
const info = inject("info");
const configList = inject("configList");
const toggleVnt = inject("toggleVnt");
const restartVnt = inject("restartVnt");
const loading = inject("loading");
const localSelectedConfig = ref("");
// 同步当前配置
watch(
() => info.value.current_config_file,
(newVal) => {
if (
(info.value.status === "running" ||
info.value.status === "starting") &&
newVal
) {
localSelectedConfig.value = newVal;
}
},
{immediate: true},
);
const handleToggle = () => {
// 如果是启动,且有本地选择的配置,传递给 toggle
if (
info.value.status !== "running" &&
info.value.status !== "starting"
) {
toggleVnt(localSelectedConfig.value);
} else {
toggleVnt(null);
}
};
const handleRestart = () => {
if (localSelectedConfig.value) {
restartVnt(localSelectedConfig.value);
}
};
return {
info,
configList,
localSelectedConfig,
loading,
handleToggle,
handleRestart,
};
},
};
const ConfigView = {
template: "#tpl-config",
setup() {
const info = inject("info");
const configList = inject("configList");
const fetchConfigList = inject("fetchConfigList");
// 编辑器状态
const showEditor = ref(false);
const editorContent = ref("");
const editorFileName = ref("");
const editorMode = ref("new");
const editMode = ref("form"); // 'form' 或 'toml'
const originalToml = ref(""); // 保存原始TOML内容(包含用户注释)
const hasTomlChanges = ref(false); // 标记TOML是否被修改过
const hasFormChanges = ref(false); // 标记表单是否被修改过
const isParsingToml = ref(false); // 标记是否正在解析TOML(用于避免触发watch)
// 表单数据
const formData = ref({
config_name: "",
network_code: "",
server: [""],
ip: "",
mtu: null,
rtx: false,
fec: false,
compress: false,
no_punch: false,
input: [],
output: [],
no_nat: false,
no_tun: false,
port_mapping: [],
allow_mapping: false,
device_name: "",
device_id: "",
tun_name: "",
password: "",
cert_mode: "skip",
fingerprint: "",
udp_stun: [],
tcp_stun: []
});
// 从TOML解析到表单
const parseTomlToForm = (toml) => {
const data = {
config_name: "",
network_code: "",
server: [""],
ip: "",
mtu: null,
rtx: false,
fec: false,
compress: false,
no_punch: false,
input: [],
output: [],
no_nat: false,
no_tun: false,
port_mapping: [],
allow_mapping: false,
device_name: "",
device_id: "",
tun_name: "",
password: "",
cert_mode: "skip",
fingerprint: "",
udp_stun: [],
tcp_stun: []
};
const lines = toml.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (trimmed.includes('config_name')) {
const match = trimmed.match(/config_name\s*=\s*"([^"]*)"/);
if (match) data.config_name = match[1];
} else if (trimmed.includes('network_code')) {
const match = trimmed.match(/network_code\s*=\s*"([^"]*)"/);
if (match) data.network_code = match[1];
} else if (trimmed.startsWith('server')) {
const match = trimmed.match(/server\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.server = items.map(s => s.replace(/"/g, ''));
}
} else if (trimmed.includes('ip =')) {
const match = trimmed.match(/ip\s*=\s*"([^"]*)"/);
if (match) data.ip = match[1];
} else if (trimmed.includes('mtu =')) {
const match = trimmed.match(/mtu\s*=\s*(\d+)/);
if (match) data.mtu = parseInt(match[1]);
} else if (trimmed.match(/^rtx\s*=/)) {
data.rtx = trimmed.includes('true');
} else if (trimmed.match(/^fec\s*=/)) {
data.fec = trimmed.includes('true');
} else if (trimmed.match(/^compress\s*=/)) {
data.compress = trimmed.includes('true');
} else if (trimmed.match(/^no_punch\s*=/)) {
data.no_punch = trimmed.includes('true');
} else if (trimmed.startsWith('input')) {
const match = trimmed.match(/input\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.input = items.map(s => s.replace(/"/g, ''));
}
} else if (trimmed.startsWith('output')) {
const match = trimmed.match(/output\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.output = items.map(s => s.replace(/"/g, ''));
}
} else if (trimmed.match(/^no_nat\s*=/)) {
data.no_nat = trimmed.includes('true');
} else if (trimmed.match(/^no_tun\s*=/)) {
data.no_tun = trimmed.includes('true');
} else if (trimmed.startsWith('port_mapping')) {
const match = trimmed.match(/port_mapping\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.port_mapping = items.map(s => s.replace(/"/g, ''));
}
} else if (trimmed.match(/^allow_mapping\s*=/)) {
data.allow_mapping = trimmed.includes('true');
} else if (trimmed.includes('device_name')) {
const match = trimmed.match(/device_name\s*=\s*"([^"]*)"/);
if (match) data.device_name = match[1];
} else if (trimmed.includes('device_id')) {
const match = trimmed.match(/device_id\s*=\s*"([^"]*)"/);
if (match) data.device_id = match[1];
} else if (trimmed.includes('tun_name')) {
const match = trimmed.match(/tun_name\s*=\s*"([^"]*)"/);
if (match) data.tun_name = match[1];
} else if (trimmed.includes('password =')) {
const match = trimmed.match(/password\s*=\s*"([^"]*)"/);
if (match) data.password = match[1];
} else if (trimmed.includes('cert_mode')) {
const match = trimmed.match(/cert_mode\s*=\s*"([^"]*)"/);
if (match) {
const value = match[1];
if (value.startsWith('finger:')) {
data.cert_mode = 'finger';
data.fingerprint = value.substring(7); // 去掉 "finger:" 前缀
} else {
data.cert_mode = value;
}
}
} else if (trimmed.startsWith('udp_stun')) {
const match = trimmed.match(/udp_stun\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.udp_stun = items.map(s => s.replace(/"/g, ''));
}
} else if (trimmed.startsWith('tcp_stun')) {
const match = trimmed.match(/tcp_stun\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.tcp_stun = items.map(s => s.replace(/"/g, ''));
}
}
}
return data;
};
// 从表单生成TOML
const formToToml = () => {
let toml = '';
if (formData.value.config_name) {
toml += `# 配置名称\nconfig_name = "${formData.value.config_name}"\n`;
}
toml += '\n# --- 网络配置 ---\n';
toml += '# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)\n';
toml += `network_code = "${formData.value.network_code}"\n\n`;
const servers = formData.value.server.filter(s => s.trim());
if (servers.length > 0) {
toml += '# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)\n';
toml += '# dynamic 协议使用dns txt解析记录值\n';
toml += `server = [${servers.map(s => `"${s}"`).join(', ')}]\n`;
}
if (formData.value.ip) {
toml += '\n# 自定义虚拟 IP (可选)\n';
toml += `ip = "${formData.value.ip}"\n`;
}
if (formData.value.rtx) {
toml += '\n# 是否启用quic优化传输 (默认 false)\n';
toml += '# 开启后传输过程几乎不会丢包,但是延迟可能会有波动\n';
toml += 'rtx = true\n';
}
if (formData.value.fec) {
toml += '\n# 是否启用 FEC 前向纠错 (默认 false)\n';
toml += '# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能\n';
toml += 'fec = true\n';
}
if (formData.value.no_punch) {
toml += '\n# 是否关闭 P2P 打洞 (默认 false)\n';
toml += 'no_punch = true\n';
}
if (formData.value.compress) {
toml += '\n# 是否启用 LZ4 压缩 (默认 false)\n';
toml += 'compress = true\n';
}
const inputs = formData.value.input.filter(s => s.trim());
if (inputs.length > 0) {
toml += '\n# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点\n';
toml += '# 例如192.168.0.0/24,10.26.0.2 表示将192.168.0.0/24网段的数据转发到10.26.0.2\n';
toml += `input = [${inputs.map(s => `"${s}"`).join(', ')}]\n`;
}
const outputs = formData.value.output.filter(s => s.trim());
if (outputs.length > 0) {
toml += '\n# 出栈允许网段,用于点对网,允许指定网段的转发\n';
toml += `output = [${outputs.map(s => `"${s}"`).join(', ')}]\n`;
}
if (formData.value.no_nat) {
toml += '\n# 是否关闭内置子网NAT,关闭后需要配置网卡转发,否则无法使用点对网\n';
toml += '# 通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好\n';
toml += 'no_nat = true\n';
}
if (formData.value.no_tun) {
toml += '\n# 是否关闭TUN虚拟网卡,关闭后只能充当流量出口或者进行端口映射,关闭后无需管理员权限\n';
toml += 'no_tun = true\n';
}
const portMappings = formData.value.port_mapping.filter(s => s.trim());
if (portMappings.length > 0) {
toml += '\n# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址\n';
toml += '# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址\n';
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 表示将本地tcp的81端口的数据转发到10.0.0.2:80\n';
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80\n';
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80\n';
toml += `port_mapping = [${portMappings.map(s => `"${s}"`).join(', ')}]\n`;
}
if (formData.value.allow_mapping) {
toml += '\n# 是否允许作为端口映射出口,开启后其他设备才可使用本设备的ip为"目标虚拟IP"\n';
toml += '# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络\n';
toml += 'allow_mapping = true\n';
}
if (formData.value.mtu) {
toml += '\n# MTU 设置\n';
toml += `mtu = ${formData.value.mtu}\n`;
}
toml += '\n# --- 设备配置 ---\n';
if (formData.value.device_name) {
toml += '\n# 设备名称 (可选,默认读取本机 hostname)\n';
toml += `device_name = "${formData.value.device_name}"\n`;
}
if (formData.value.device_id) {
toml += '\n# 设备 ID (可选,不填自动生成,不同设备ID不能相同)\n';
toml += `device_id = "${formData.value.device_id}"\n`;
}
if (formData.value.tun_name) {
toml += '\n# 虚拟网卡名称\n';
toml += `tun_name = "${formData.value.tun_name}"\n`;
}
toml += '\n# --- 安全配置 ---\n';
if (formData.value.password) {
toml += '\n# 组网加密密码 (可选)\n';
toml += `password = "${formData.value.password}"\n`;
}
if (formData.value.cert_mode && formData.value.cert_mode !== 'skip') {
toml += '\n# 证书校验方式:\n';
toml += '# skip 跳过验证(默认)\n';
toml += '# standard 使用系统证书验证\n';
toml += '# finger 使用证书指纹验证,服务端启动时日志会输出指纹\n';
if (formData.value.cert_mode === 'finger' && formData.value.fingerprint) {
toml += `cert_mode = "finger:${formData.value.fingerprint}"\n`;
} else {
toml += `cert_mode = "${formData.value.cert_mode}"\n`;
}
}
const udpStuns = formData.value.udp_stun.filter(s => s.trim());
if (udpStuns.length > 0) {
toml += '\n# 自定义UDP STUN地址,不设置则用默认stun\n';
toml += `udp_stun = [${udpStuns.map(s => `"${s}"`).join(', ')}]\n`;
}
const tcpStuns = formData.value.tcp_stun.filter(s => s.trim());
if (tcpStuns.length > 0) {
toml += '\n# 自定义TCP STUN地址,不设置则用默认stun\n';
toml += `tcp_stun = [${tcpStuns.map(s => `"${s}"`).join(', ')}]\n`;
}
return toml;
};
// 切换到表单模式
const switchToFormMode = () => {
if (editMode.value === 'toml') {
editMode.value = 'form';
// 从TOML解析到表单
isParsingToml.value = true;
formData.value = parseTomlToForm(editorContent.value);
nextTick(() => {
isParsingToml.value = false;
});
} else {
editMode.value = 'form';
}
};
// 切换到TOML模式
const switchToTomlMode = () => {
if (editMode.value === 'form') {
// 如果表单被修改过,生成新的TOML
if (hasFormChanges.value) {
editorContent.value = formToToml();
// 重置标记,因为表单修改已经应用到TOML了
hasFormChanges.value = false;
} else if (originalToml.value && !hasTomlChanges.value) {
// 如果表单没被修改,且TOML也没被修改,使用原始TOML(保留用户注释)
editorContent.value = originalToml.value;
} else {
// 其他情况生成新的TOML
editorContent.value = formToToml();
}
}
editMode.value = 'toml';
};
// 监听TOML内容变化(只在TOML模式下)
watch(editorContent, (newVal, oldVal) => {
if (editMode.value === 'toml' && oldVal !== undefined) {
hasTomlChanges.value = true;
}
});
// 监听表单数据变化
watch(formData, () => {
if (editMode.value === 'form' && showEditor.value && !isParsingToml.value) {
hasFormChanges.value = true;
}
}, { deep: true });
const openEditor = async (fileName) => {
editorFileName.value = fileName || "";
editorMode.value = fileName ? "edit" : "new";
editMode.value = "form"; // 默认表单模式
hasTomlChanges.value = false; // 重置TOML修改标记
hasFormChanges.value = false; // 重置表单修改标记
if (fileName) {
try {
const res = await fetch(
`${API_BASE}/api/config?file_name=${fileName}`,
);
const json = await res.json();
if (json.code === 0) {
editorContent.value = json.data;
originalToml.value = json.data; // 保存原始TOML
isParsingToml.value = true;
formData.value = parseTomlToForm(json.data);
nextTick(() => {
isParsingToml.value = false;
});
showEditor.value = true;
} else alert("获取配置失败: " + json.msg);
} catch (e) {
alert("网络错误");
}
} else {
// 新建配置,初始化表单
originalToml.value = ""; // 新建时清空原始TOML
formData.value = {
config_name: "",
network_code: "",
server: [""],
ip: "",
mtu: null,
rtx: false,
fec: false,
compress: false,
no_punch: false,
input: [],
output: [],
no_nat: false,
no_tun: false,
port_mapping: [],
allow_mapping: false,
device_name: "",
device_id: "",
tun_name: "",
password: "",
cert_mode: "skip",
fingerprint: "",
udp_stun: [],
tcp_stun: []
};
editorContent.value = `# config_name = "配置名称"
# --- 网络配置 ---
# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)
network_code = "your_network_code"
# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)
# dynamic 协议使用dns txt解析记录值
server = ["quic://1.2.3.4:29872"]
# ===简单使用以下参数可以不动===
# 自定义虚拟 IP (可选)
# ip = "10.10.0.2"
# 是否启用quic优化传输 (默认 false,设置为true时开启)
# 开启后传输过程几乎不会丢包,但是延迟会有波动
# rtx = false
# 是否启用 FEC 前向纠错,损失一定带宽来提升网络稳定性(默认 false,设置为true时开启)
# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能
# fec = false
# 是否关闭 P2P 打洞 (默认 false,设置为true时关闭)
# no_punch = false
# 是否启用 LZ4 压缩 (默认 false,设置为true时开启)
# compress = false
# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点
# input = ["192.168.0.0/24,10.26.0.2", "192.168.1.0/24,10.26.0.3"]
# 出栈允许网段,用于点对网,允许指定网段的转发
# output = ["0.0.0.0/0"]
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
# no_nat = false
# 是否关闭TUN虚拟网卡,关闭(设为true)后只能充当流量出口或者进行端口映射,关闭后无需管理员权限
# no_tun = false
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
# 例如 port_mapping = ["tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"]
# tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 则表示将本地tcp的81端口的数据转发到10.0.0.2:80
# tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80
# tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80
# port_mapping = []
# 是否允许作为端口映射出口,开启(设置为true)后其他设备才可使用本设备的ip为"目标虚拟IP"
# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络
# allow_mapping = false
# MTU 设置
# mtu = 1400
# --- 设备配置 ---
# 设备名称 (可选,默认读取本机 hostname)
# device_name = "my-device"
# 设备 ID (可选,不填自动生成,不同设备ID不能相同)
# device_id = "device-id-xxxx"
# 虚拟网卡名称
# tun_name = "vnt-tun"
# --- 安全配置 ---
# 加密密码 (可选)
# password = "123456"
# 证书校验方式:
# skip 跳过验证(默认)
# standard 使用系统证书验证
# finger 使用证书指纹验证,服务端启动时日志会输出指纹,
# 例如 finger:3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1
# cert_mode = "skip"
# --- 其他配置 ---
# 自定义stun地址,分别用于udp打洞和tcp打洞,需要单独配置,不设置则用默认stun
# udp_stun = ["stun.chat.bilibili.com"]
# tcp_stun = ["stun.nextcloud.com:443"]`;
showEditor.value = true;
}
};
const saveConfig = async () => {
try {
// 如果是表单模式,先转换为TOML
let configContent = editorContent.value;
if (editMode.value === 'form') {
// 验证必填项
if (!formData.value.network_code.trim()) {
alert('请填写网络编号');
return;
}
const servers = formData.value.server.filter(s => s.trim());
if (servers.length === 0) {
alert('请至少填写一个服务器地址');
return;
}
configContent = formToToml();
}
const payload = {
file_name: editorFileName.value || null,
config: configContent,
};
const res = await fetch(`${API_BASE}/api/config`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
const json = await res.json();
if (json.code === 0) {
showEditor.value = false;
fetchConfigList();
} else alert("保存失败: " + json.msg);
} catch (e) {
alert("保存失败: " + e.message);
}
};
const deleteConfig = async (fileName) => {
if (!confirm(`确定要删除配置 ${fileName} 吗?`)) return;
try {
const res = await fetch(
`${API_BASE}/api/config?file_name=${fileName}`,
{method: "DELETE"},
);
const json = await res.json();
if (json.code === 0) fetchConfigList();
else alert(json.msg);
} catch (e) {
alert(e.message);
}
};
return {
info,
configList,
showEditor,
editorContent,
editorFileName,
editorMode,
editMode,
formData,
openEditor,
saveConfig,
deleteConfig,
switchToFormMode,
switchToTomlMode,
};
},
};
const PeersView = {
template: "#tpl-peers",
setup() {
const peers = ref([]);
const info = inject("info");
const isPageVisible = inject("isPageVisible");
const showTooltipGlobal = inject("showPeerTooltip");
const hideTooltipGlobal = inject("hidePeerTooltip");
let timer = null;
// 记录上次流量数据和时间,用于前端计算网速
let lastTrafficMap = {};
let lastFetchTime = 0;
// 展开状态和网速历史
const expandedPeers = reactive({});
const speedHistoryMap = {};
const HISTORY_SIZE = 60;
const toggleExpand = (ip) => {
expandedPeers[ip] = !expandedPeers[ip];
if (expandedPeers[ip]) {
nextTick(() => drawChart(ip));
}
};
const drawChart = (ip) => {
const canvasId = 'chart-' + ip.replaceAll('.', '-');
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const history = speedHistoryMap[ip];
const txArr = history ? history.tx : [];
const rxArr = history ? history.rx : [];
// 高清适配
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
const padTop = 8, padBottom = 4, padLeft = 0, padRight = 0;
const chartW = w - padLeft - padRight;
const chartH = h - padTop - padBottom;
// 背景
ctx.fillStyle = '#0c1222';
ctx.fillRect(0, 0, w, h);
// 计算Y轴最大值
const allValues = [...txArr, ...rxArr];
let maxVal = allValues.length > 0 ? Math.max(...allValues) : 0;
if (maxVal < 1024) maxVal = 1024; // 最小1KB
// 向上取整到合适的刻度
const niceMax = niceNumber(maxVal);
// 更新最大值标签
const maxLabel = document.getElementById('chart-max-' + ip.replaceAll('.', '-'));
if (maxLabel) maxLabel.textContent = '峰值: ' + formatSpeed(niceMax);
// 网格线
const gridLines = 4;
ctx.strokeStyle = 'rgba(71, 85, 105, 0.3)';
ctx.lineWidth = 1;
for (let i = 0; i <= gridLines; i++) {
const y = padTop + (chartH / gridLines) * i;
ctx.beginPath();
ctx.moveTo(padLeft, y);
ctx.lineTo(padLeft + chartW, y);
ctx.stroke();
}
// 垂直网格线
const vLines = 6;
for (let i = 0; i <= vLines; i++) {
const x = padLeft + (chartW / vLines) * i;
ctx.beginPath();
ctx.moveTo(x, padTop);
ctx.lineTo(x, padTop + chartH);
ctx.stroke();
}
// 绘制曲线
const drawLine = (data, strokeColor, fillColor) => {
if (data.length < 2) return;
const step = chartW / (HISTORY_SIZE - 1);
const offset = HISTORY_SIZE - data.length;
// 填充区域
ctx.beginPath();
ctx.moveTo(padLeft + offset * step, padTop + chartH);
for (let i = 0; i < data.length; i++) {
const x = padLeft + (offset + i) * step;
const y = padTop + chartH - (data[i] / niceMax) * chartH;
if (i === 0) ctx.lineTo(x, y);
else ctx.lineTo(x, y);
}
ctx.lineTo(padLeft + (offset + data.length - 1) * step, padTop + chartH);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// 线条
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = padLeft + (offset + i) * step;
const y = padTop + chartH - (data[i] / niceMax) * chartH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1.5;
ctx.stroke();
};
drawLine(rxArr, '#60a5fa', 'rgba(96, 165, 250, 0.15)');
drawLine(txArr, '#4ade80', 'rgba(74, 222, 128, 0.15)');
};
// 将数值取整到适合的刻度
const niceNumber = (val) => {
const units = [
1024, // 1KB
10 * 1024, // 10KB
100 * 1024, // 100KB
1024 * 1024, // 1MB
10 * 1024 * 1024, // 10MB
100 * 1024 * 1024,// 100MB
1024 * 1024 * 1024,// 1GB
];
for (const u of units) {
if (val <= u) return u;
}
return Math.ceil(val / (1024 * 1024 * 1024)) * 1024 * 1024 * 1024;
};
const fetchPeers = async () => {
if (info.value.status !== "running") {
peers.value = [];
lastTrafficMap = {};
lastFetchTime = 0;
return;
}
try {
const res = await fetch(`${API_BASE}/api/peers`);
const json = await res.json();
if (json.code === 0) {
const now = Date.now();
const list = json.data || [];
const elapsed = lastFetchTime > 0 ? (now - lastFetchTime) / 1000 : 0;
const newTrafficMap = {};
for (const peer of list) {
if (peer.traffic) {
const key = peer.ip;
const prev = lastTrafficMap[key];
if (prev && elapsed > 0) {
const txDiff = Math.max(0, peer.traffic.tx_bytes - prev.tx_bytes);
const rxDiff = Math.max(0, peer.traffic.rx_bytes - prev.rx_bytes);
peer.traffic.tx_speed = Math.round(txDiff / elapsed);
peer.traffic.rx_speed = Math.round(rxDiff / elapsed);
} else {
peer.traffic.tx_speed = 0;
peer.traffic.rx_speed = 0;
}
newTrafficMap[key] = { tx_bytes: peer.traffic.tx_bytes, rx_bytes: peer.traffic.rx_bytes };
// 记录速度历史
if (!speedHistoryMap[key]) speedHistoryMap[key] = { tx: [], rx: [] };
speedHistoryMap[key].tx.push(peer.traffic.tx_speed);
speedHistoryMap[key].rx.push(peer.traffic.rx_speed);
if (speedHistoryMap[key].tx.length > HISTORY_SIZE) {
speedHistoryMap[key].tx.shift();
speedHistoryMap[key].rx.shift();
}
}
}
lastTrafficMap = newTrafficMap;
lastFetchTime = now;
peers.value = list;
// 重绘所有展开的图表
nextTick(() => {
for (const ip in expandedPeers) {
if (expandedPeers[ip]) drawChart(ip);
}
});
}
} catch (e) {
console.error(e);
}
};
onMounted(() => {
fetchPeers();
timer = setInterval(() => {
if (isPageVisible.value) fetchPeers();
}, 3000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
// 监听 status 变化,当变为 running 时立即获取数据
watch(() => info.value.status, (newStatus) => {
if (newStatus === "running") {
fetchPeers();
}
});
const getRouteModeClass = (route) => {
// route 包含 addr, protocol, metric, rtt
// 判断是否直连:metric === 1
const isDirect = route.metric === 1;
if (isDirect) {
return 'text-purple-400 font-medium'; // 直连 - 紫色
} else {
return 'text-blue-400'; // 客户端中继 - 蓝色
}
};
const getRouteModeText = (route) => {
const isDirect = route.metric === 1;
const isTcp = route.protocol.includes('Tcp');
if (isDirect) {
return isTcp ? '打洞TCP直连' : '打洞UDP直连';
} else {
return isTcp ? '客户端TCP中继' : '客户端UDP中继';
}
};
return {
peers,
expandedPeers,
toggleExpand,
formatTime,
formatBytes,
formatSpeed,
getRouteModeClass,
getRouteModeText,
showPeerTooltip: showTooltipGlobal,
hidePeerTooltip: hideTooltipGlobal,
};
},
};
const RoutesView = {
template: "#tpl-routes",
setup() {
const routes = ref([]);
const info = inject("info");
const isPageVisible = inject("isPageVisible");
let timer = null;
const fetchRoutes = async () => {
if (info.value.status !== "running") {
routes.value = [];
return;
}
try {
const res = await fetch(`${API_BASE}/api/routes`);
const json = await res.json();
if (json.code === 0) routes.value = json.data || [];
} catch (e) {
console.error(e);
}
};
onMounted(() => {
fetchRoutes();
timer = setInterval(() => {
if (isPageVisible.value) fetchRoutes();
}, 3000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
// 监听 status 变化,当变为 running 时立即获取数据
watch(() => info.value.status, (newStatus) => {
if (newStatus === "running") {
fetchRoutes();
}
});
return {routes};
},
};
// --- 路由配置 ---
const routes = [
{path: "/", redirect: "/general"},
{path: "/general", component: GeneralView},
{path: "/config", component: ConfigView},
{path: "/peers", component: PeersView},
{path: "/routes", component: RoutesView},
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
// --- 主应用 ---
createApp({
setup() {
const info = ref({
status: "stopped",
ip: "",
name: "",
device_id: "",
version: "",
server_info: [],
online_client_num: 0,
direct_client_num: 0,
offline_client_num: 0,
current_config_file: null,
current_config_name: null,
});
const configList = ref([]);
const loading = ref(false);
// 启动日志相关
const showStartLog = ref(false);
const startLogs = ref([]);
const startStatus = ref("stopped");
const logContainer = ref(null);
let statusInterval = null;
let infoTimer = null;
// Tooltip 相关
const tooltipState = ref({
show: false,
x: 0,
y: 0,
info: null,
});
let tooltipHideTimer = null;
const showPeerTooltip = (event, peer) => {
if (!peer.nat_info) return;
if (tooltipHideTimer) {
clearTimeout(tooltipHideTimer);
tooltipHideTimer = null;
}
const rect =
event.currentTarget.getBoundingClientRect();
tooltipState.value = {
show: true,
x: rect.left + rect.width / 2,
y: rect.bottom + 10,
info: peer.nat_info,
};
};
const hidePeerTooltip = () => {
tooltipHideTimer = setTimeout(() => {
tooltipState.value.show = false;
}, 100);
};
const onTooltipEnter = () => {
if (tooltipHideTimer) {
clearTimeout(tooltipHideTimer);
tooltipHideTimer = null;
}
};
const onTooltipLeave = () => {
tooltipState.value.show = false;
};
// 计算属性
const isServerConnected = computed(
() =>
info.value.server_info &&
info.value.server_info.some((s) => s.connected),
);
const serverStatusText = computed(() => {
if (
!info.value.server_info ||
!info.value.server_info.length
)
return "未配置服务器";
return `${info.value.server_info.filter((s) => s.connected).length} / ${info.value.server_info.length} 已连接`;
});
// 基础 API
const fetchInfo = async () => {
try {
const res = await fetch(`${API_BASE}/api/info`);
const json = await res.json();
if (json.code === 0) info.value = json.data;
} catch (e) {
console.error("Fetch info error", e);
}
};
const fetchConfigList = async () => {
try {
const res = await fetch(
`${API_BASE}/api/config/list`,
);
const json = await res.json();
if (json.code === 0) configList.value = json.data;
} catch (e) {
console.error("Fetch list error", e);
}
};
// 启动流程控制
const pollStartStatus = async () => {
try {
const res = await fetch(
`${API_BASE}/api/start/status`,
);
const json = await res.json();
if (json.code === 0) {
startLogs.value = json.data.logs || [];
startStatus.value = json.data.status;
nextTick(() => {
if (logContainer.value)
logContainer.value.scrollTop =
logContainer.value.scrollHeight;
});
if (startStatus.value === "running") {
stopPolling();
fetchInfo();
showStartLog.value = false;
} else if (
startStatus.value === "stopped" &&
startLogs.value.length > 0
) {
stopPolling();
fetchInfo();
}
}
} catch (e) {
console.error(e);
}
};
const stopPolling = () => {
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
}
};
const startPolling = () => {
stopPolling();
statusInterval = setInterval(pollStartStatus, 1000);
pollStartStatus();
};
const openStartingModal = () => {
startLogs.value = [];
startStatus.value = "starting";
showStartLog.value = true;
startPolling();
};
const toggleVnt = async (selectedFile) => {
if (loading.value) return;
// 停止逻辑
if (
info.value.status === "running" ||
info.value.status === "starting"
) {
loading.value = true;
await fetch(`${API_BASE}/api/stop`, {
method: "POST",
});
loading.value = false;
stopPolling();
showStartLog.value = false;
fetchInfo();
return;
}
// 启动逻辑
if (!selectedFile) {
alert("请先选择一个配置");
return;
}
loading.value = true;
try {
const res = await fetch(`${API_BASE}/api/start`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
file_name: selectedFile,
}),
});
const json = await res.json();
loading.value = false;
if (json.code !== 0) {
alert("启动失败: " + json.msg);
return;
}
openStartingModal();
} catch (e) {
loading.value = false;
alert("网络请求失败: " + e.message);
}
};
const cancelStart = async () => {
stopPolling();
try {
await fetch(`${API_BASE}/api/stop`, {
method: "POST",
});
startLogs.value.push("启动已手动取消");
} catch (e) {
}
startStatus.value = "stopped";
fetchInfo();
};
const restartVnt = async (selectedFile) => {
if (loading.value) return;
if (!selectedFile) {
alert("请先选择一个配置");
return;
}
loading.value = true;
try {
const res = await fetch(`${API_BASE}/api/restart`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
file_name: selectedFile,
}),
});
const json = await res.json();
loading.value = false;
if (json.code !== 0) {
alert("重启失败: " + json.msg);
return;
}
openStartingModal();
} catch (e) {
loading.value = false;
alert("网络请求失败: " + e.message);
}
};
// 导航辅助
const navClass = (isActive) =>
isActive
? "bg-blue-600 text-white shadow-lg shadow-blue-500/30"
: "text-slate-400 hover:bg-slate-800 hover:text-white";
// 页面可见性处理
const isPageVisible = ref(!document.hidden);
const visibilityHandler = () => {
isPageVisible.value = !document.hidden;
};
// 依赖注入
provide("info", info);
provide("isPageVisible", isPageVisible);
provide("configList", configList);
provide("fetchConfigList", fetchConfigList);
provide("toggleVnt", toggleVnt);
provide("restartVnt", restartVnt);
provide("loading", loading);
provide("showPeerTooltip", showPeerTooltip);
provide("hidePeerTooltip", hidePeerTooltip);
onMounted(async () => {
document.addEventListener(
"visibilitychange",
visibilityHandler,
);
await fetchInfo();
fetchConfigList();
if (info.value.status === "starting")
openStartingModal();
// 全局轮询 info (状态/IP等)
infoTimer = setInterval(() => {
if (info.value.status !== "running") return;
if (isPageVisible.value) fetchInfo();
}, 3000);
});
onUnmounted(() => {
document.removeEventListener(
"visibilitychange",
visibilityHandler,
);
stopPolling();
if (infoTimer) clearInterval(infoTimer);
});
return {
info,
isServerConnected,
serverStatusText,
navClass,
showStartLog,
startStatus,
startLogs,
logContainer,
cancelStart,
tooltipState,
onTooltipEnter,
onTooltipLeave,
};
},
})
.use(router)
.mount("#app");
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VNT Dashboard</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='0' y='0' width='64' height='64' rx='16' fill='%23ffffff' stroke='%23e2e8f0' stroke-width='2'/%3E%3Cpath d='M16 20 L32 48 L48 20' fill='none' stroke='%234f46e5' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='48' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='32' cy='48' r='6' fill='%2322c55e'/%3E%3C/svg%3E"
type="image/svg+xml"
/>
<script>
// 首屏前应用主题,避免闪烁
(function () {
var t = localStorage.getItem("vnt-theme");
var dark = t
? t === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
if (dark) document.documentElement.classList.add("dark");
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "vnt-web-ui",
"private": true,
"version": "2.0.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"pinia": "catalog:",
"qrcode": "1.5.4",
"vue": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"tailwindcss": "catalog:",
"vite": "catalog:"
}
}
+154
View File
@@ -0,0 +1,154 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "./stores/app";
import { useStartLogStore } from "./stores/startLog";
import { visibleNavItems } from "./navigation";
import AppSidebar from "./components/AppSidebar.vue";
import AppModal from "./components/AppModal.vue";
import AppTooltip from "./components/AppTooltip.vue";
import ConfirmHost from "./components/ConfirmHost.vue";
import ToastHost from "./components/ToastHost.vue";
import AccessGate from "./components/AccessGate.vue";
import { authorized, isDesktop } from "./auth";
const app = useAppStore();
const startLog = useStartLogStore();
const route = useRoute();
const router = useRouter();
const tooltipRef = ref(null);
provide("peerTooltip", tooltipRef);
const mobileNavOpen = ref(false);
const items = visibleNavItems();
const pageMeta = computed(() => items.find((item) => item.to === route.path) || items[0]);
const savedTheme = localStorage.getItem("vnt-theme");
const isDark = ref(savedTheme
? savedTheme === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches);
const applyTheme = () => document.documentElement.classList.toggle("dark", isDark.value);
const toggleTheme = () => {
isDark.value = !isDark.value;
localStorage.setItem("vnt-theme", isDark.value ? "dark" : "light");
applyTheme();
};
applyTheme();
watch(() => route.path, () => { mobileNavOpen.value = false; });
const handleKeydown = (event) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
const index = Number(event.key) - 1;
if (index >= 0 && index < items.length) {
event.preventDefault();
router.push(items[index].to);
}
};
onMounted(() => window.addEventListener("keydown", handleKeydown));
onBeforeUnmount(() => window.removeEventListener("keydown", handleKeydown));
</script>
<template>
<AccessGate v-if="!isDesktop && !authorized" />
<div v-else class="flex h-[100dvh] min-h-0 overflow-hidden bg-slate-50 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<aside class="hidden w-60 shrink-0 border-r border-slate-200 dark:border-slate-800 lg:block">
<AppSidebar />
</aside>
<transition name="drawer">
<div v-if="mobileNavOpen" class="fixed inset-0 z-40 lg:hidden">
<button class="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]" aria-label="关闭导航" @click="mobileNavOpen = false"></button>
<aside class="drawer-panel relative h-full w-[min(82vw,288px)] border-r border-slate-200 shadow-2xl dark:border-slate-700">
<AppSidebar @navigate="mobileNavOpen = false" />
</aside>
</div>
</transition>
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 bg-white/90 px-4 backdrop-blur lg:px-6 dark:border-slate-800 dark:bg-slate-900/90">
<button
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
aria-label="打开导航"
@click="mobileNavOpen = true"
>
<svg class="h-5 w-5 fill-none stroke-current" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16" stroke-linecap="round" stroke-width="2" /></svg>
</button>
<div class="min-w-0">
<h1 class="truncate text-lg font-bold text-slate-900 dark:text-white">{{ pageMeta.label }}</h1>
<p class="hidden text-xs text-slate-400 sm:block">{{ pageMeta.subtitle || "VNT 虚拟局域网管理" }}</p>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="flex h-8 items-center gap-2 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-500 sm:px-3 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
<span
class="h-1.5 w-1.5 rounded-full"
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
></span>
<span class="hidden sm:inline">{{ app.headerStatusText }}</span>
</div>
<button
class="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:border-indigo-300 hover:text-indigo-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400"
:title="isDark ? '切换浅色模式' : '切换深色模式'"
:aria-label="isDark ? '切换浅色模式' : '切换深色模式'"
@click="toggleTheme"
>
<svg v-if="isDark" class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round" stroke-width="1.7"/></svg>
<svg v-else class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><path d="M20.5 15.2A8.5 8.5 0 0 1 8.8 3.5 8.5 8.5 0 1 0 20.5 15.2Z" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7"/></svg>
</button>
</div>
</header>
<main class="custom-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div class="mx-auto w-full max-w-[1700px] px-4 py-5 sm:px-5 lg:px-7 lg:py-6">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"><component :is="Component" /></transition>
</router-view>
</div>
</main>
</div>
<AppModal
:show="startLog.showStartLog"
:mask-closable="startLog.startStatus !== 'starting'"
:esc-closable="startLog.startStatus !== 'starting'"
panel-class="w-full max-w-2xl"
@close="startLog.close"
>
<template #header>
<div class="flex min-w-0 items-center gap-3">
<span
class="h-2 w-2 shrink-0 rounded-full"
:class="startLog.startStatus === 'starting' ? 'animate-pulse bg-amber-400' : startLog.startStatus === 'running' ? 'bg-green-500' : 'bg-red-500'"
></span>
<h3 class="truncate text-base font-bold text-slate-900 sm:text-lg dark:text-white">
{{ startLog.startStatus === "starting" ? "正在建立虚拟网络" : startLog.startStatus === "running" ? "网络已连接" : "连接未完成" }}
</h3>
</div>
<span class="max-w-[40%] truncate font-mono text-xs text-indigo-600 dark:text-indigo-400">{{ startLog.logConfigName }}</span>
</template>
<template #body>
<div
:ref="(el) => (startLog.logContainer = el)"
class="custom-scrollbar h-64 space-y-2 overflow-y-auto border-y border-slate-200 bg-slate-50 p-4 font-mono text-xs text-slate-600 sm:h-80 sm:p-6 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300"
>
<div v-for="(log, idx) in startLog.startLogs" :key="idx" class="flex gap-3">
<span class="text-indigo-600 dark:text-indigo-400"></span><span class="break-all">{{ log }}</span>
</div>
<div v-if="startLog.startStatus === 'starting'" class="animate-pulse text-indigo-600 dark:text-indigo-400">等待下一阶段</div>
</div>
</template>
<template #footer>
<button v-if="startLog.startStatus === 'starting'" class="btn-ghost" @click="startLog.cancelStart">取消连接</button>
<button v-else class="btn-primary" @click="startLog.close">完成</button>
</template>
</AppModal>
<ToastHost />
<ConfirmHost />
<AppTooltip ref="tooltipRef" />
</div>
</template>
+95
View File
@@ -0,0 +1,95 @@
import { clearAccessToken, getAccessToken } from "../auth";
// HTTP 与 Tauri IPC 共用相同的 ApiResponse{code,msg,data} 协议。
const request = async (url, options = {}) => {
if (globalThis.__VNT_IPC_REQUEST__) {
const json = await globalThis.__VNT_IPC_REQUEST__({
method: options.method || "GET",
path: url,
body: options.body || null,
});
if (json.code !== 0) throw new Error(json.msg || "请求失败");
return json.data;
}
const headers = new Headers(options.headers || {});
const token = getAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(url, { ...options, headers });
const json = await res.json();
if (res.status === 401) clearAccessToken();
if (json.code !== 0) {
throw new Error(json.msg || "请求失败");
}
return json.data;
};
const jsonHeaders = { "Content-Type": "application/json" };
// GET /api/info?file_name=
export const getInstanceInfo = (fileName) =>
request(`/api/info?file_name=${encodeURIComponent(fileName)}`);
// GET /api/peers?file_name=
export const getPeers = (fileName) =>
request(`/api/peers?file_name=${encodeURIComponent(fileName)}`);
// GET /api/routes?file_name=
export const getRoutes = (fileName) =>
request(`/api/routes?file_name=${encodeURIComponent(fileName)}`);
// GET /api/start/status?file_name=
export const getStartStatus = (fileName) =>
request(`/api/start/status?file_name=${encodeURIComponent(fileName)}`);
// GET /api/version
export const getVersion = () => request("/api/version");
// GET /api/runtime
export const getRuntime = () => request("/api/runtime");
// GET /api/instances
export const getInstances = () => request("/api/instances");
// DELETE /api/instance?file_name=
export const deleteInstance = (fileName) =>
request(`/api/instance?file_name=${encodeURIComponent(fileName)}`, {
method: "DELETE",
});
const postAction = (path, fileName) =>
request(path, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ file_name: fileName }),
});
// POST /api/start
export const startVntApi = (fileName) => postAction("/api/start", fileName);
// POST /api/stop
export const stopVntApi = (fileName) => postAction("/api/stop", fileName);
// POST /api/restart
export const restartVntApi = (fileName) => postAction("/api/restart", fileName);
// GET /api/config/list
export const getConfigList = () => request("/api/config/list");
// GET /api/config?file_name= -> TOML 原文字符串
export const getConfig = (fileName) =>
request(`/api/config?file_name=${encodeURIComponent(fileName)}`);
// POST /api/config, JSON {file_name?, config}
export const saveConfig = (fileName, config) =>
request("/api/config", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ file_name: fileName || null, config }),
});
// DELETE /api/config?file_name=
export const deleteConfig = (fileName) =>
request(`/api/config?file_name=${encodeURIComponent(fileName)}`, {
method: "DELETE",
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+32
View File
@@ -0,0 +1,32 @@
import { ref } from "vue";
const STORAGE_KEY = "vnt-web-access-token";
export const isDesktop = Boolean(globalThis.__VNT_DESKTOP__);
const url = new URL(window.location.href);
const tokenFromUrl = url.searchParams.get("token") || "";
if (tokenFromUrl) {
localStorage.setItem(STORAGE_KEY, tokenFromUrl);
url.searchParams.delete("token");
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}
export const accessToken = ref(
isDesktop ? "" : tokenFromUrl || localStorage.getItem(STORAGE_KEY) || "",
);
export const authorized = ref(isDesktop || Boolean(accessToken.value));
export const getAccessToken = () => accessToken.value;
export const saveAccessToken = (token) => {
const normalized = token.trim();
localStorage.setItem(STORAGE_KEY, normalized);
accessToken.value = normalized;
authorized.value = Boolean(normalized);
};
export const clearAccessToken = () => {
localStorage.removeItem(STORAGE_KEY);
accessToken.value = "";
authorized.value = false;
};
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { ref } from "vue";
import { saveAccessToken } from "../auth";
import vntIcon from "../assets/vnt-icon.png";
const token = ref("");
const submit = () => {
if (!token.value.trim()) return;
saveAccessToken(token.value);
window.location.reload();
};
</script>
<template>
<main class="grid h-[100dvh] place-items-center overflow-y-auto bg-slate-50 p-4 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<form class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900" @submit.prevent="submit">
<div class="mb-7 flex items-center gap-3">
<img :src="vntIcon" alt="" class="h-10 w-10 shrink-0" />
<div>
<h1 class="text-lg font-bold text-slate-900 dark:text-white">访问 VNT 控制台</h1>
<p class="mt-0.5 text-xs text-slate-400">请输入桌面端 Web 访问设置中的令牌</p>
</div>
</div>
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200" for="access-token">访问令牌</label>
<input id="access-token" v-model="token" class="input font-mono" type="password" autocomplete="current-password" autofocus placeholder="粘贴访问令牌" />
<button class="btn-primary mt-5 w-full" type="submit" :disabled="!token.trim()">进入控制台</button>
<p class="mt-5 text-center text-xs leading-5 text-slate-400">令牌只保存在当前浏览器中可随时在桌面端重新生成</p>
</form>
</main>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup>
import { watch, onUnmounted } from "vue";
const props = defineProps({
show: { type: Boolean, default: false },
//
maskClosable: { type: Boolean, default: true },
// ESC
escClosable: { type: Boolean, default: true },
panelClass: { type: String, default: "w-full max-w-2xl" },
});
const emit = defineEmits(["close"]);
const onKeydown = (e) => {
if (props.escClosable && e.key === "Escape") emit("close");
};
watch(
() => props.show,
(val) => {
if (val) window.addEventListener("keydown", onKeydown);
else window.removeEventListener("keydown", onKeydown);
},
);
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
const onMaskClick = () => {
if (props.maskClosable) emit("close");
};
</script>
<template>
<teleport to="body">
<transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4 backdrop-blur-sm"
@click.self="onMaskClick"
>
<div
class="modal-panel flex max-h-[90vh] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
:class="panelClass"
>
<div
v-if="$slots.header"
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
>
<slot name="header" />
</div>
<div class="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
<slot name="body" />
</div>
<div
v-if="$slots.footer"
class="flex shrink-0 items-center justify-end gap-3 border-t border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
>
<slot name="footer" />
</div>
</div>
</div>
</transition>
</teleport>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { default: null },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const root = ref(null);
const trigger = ref(null);
const open = ref(false);
const activeIndex = ref(-1);
const listboxId = `app-select-${useId().replaceAll(":", "")}`;
const selectedIndex = computed(() =>
props.options.findIndex((option) => Object.is(option.value, props.modelValue)),
);
const selectedOption = computed(() => props.options[selectedIndex.value] || null);
const firstEnabledIndex = (from, direction) => {
if (!props.options.length) return -1;
let index = from;
for (let count = 0; count < props.options.length; count += 1) {
index = (index + direction + props.options.length) % props.options.length;
if (!props.options[index]?.disabled) return index;
}
return -1;
};
const openMenu = async () => {
if (props.disabled) return;
open.value = true;
activeIndex.value = selectedIndex.value >= 0
? selectedIndex.value
: firstEnabledIndex(-1, 1);
await nextTick();
root.value?.querySelector(`[data-option-index="${activeIndex.value}"]`)?.scrollIntoView({ block: "nearest" });
};
const closeMenu = (restoreFocus = false) => {
open.value = false;
if (restoreFocus) trigger.value?.focus();
};
const choose = (option) => {
if (option.disabled) return;
emit("update:modelValue", option.value);
closeMenu(true);
};
const moveActive = (direction) => {
activeIndex.value = firstEnabledIndex(activeIndex.value, direction);
nextTick(() => {
root.value?.querySelector(`[data-option-index="${activeIndex.value}"]`)?.scrollIntoView({ block: "nearest" });
});
};
const onKeydown = (event) => {
if (props.disabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open.value) openMenu();
else moveActive(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!open.value) openMenu();
else if (activeIndex.value >= 0) choose(props.options[activeIndex.value]);
return;
}
if (event.key === "Escape" && open.value) {
event.preventDefault();
closeMenu(true);
return;
}
if (event.key === "Home" && open.value) {
event.preventDefault();
activeIndex.value = firstEnabledIndex(-1, 1);
} else if (event.key === "End" && open.value) {
event.preventDefault();
activeIndex.value = firstEnabledIndex(0, -1);
} else if (event.key === "Tab") {
closeMenu();
}
};
const onDocumentPointerDown = (event) => {
if (open.value && !root.value?.contains(event.target)) closeMenu();
};
watch(() => props.disabled, (disabled) => {
if (disabled) closeMenu();
});
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown));
onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown));
</script>
<template>
<div ref="root" class="relative w-full">
<button
ref="trigger"
v-bind="$attrs"
type="button"
role="combobox"
:aria-expanded="open"
:aria-controls="listboxId"
aria-haspopup="listbox"
:disabled="disabled"
class="input flex min-h-10 items-center justify-between gap-3 text-left"
:class="open ? 'border-indigo-500 ring-2 ring-indigo-500/25' : ''"
@click="open ? closeMenu() : openMenu()"
@keydown="onKeydown"
>
<span class="min-w-0 flex-1 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
{{ selectedOption?.label || placeholder }}
</span>
<svg
class="h-4 w-4 shrink-0 fill-none stroke-current text-slate-400 transition-transform duration-150"
:class="open ? 'rotate-180 text-indigo-500' : ''"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m7 10 5 5 5-5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" />
</svg>
</button>
<transition name="select-menu">
<div
v-if="open"
:id="listboxId"
role="listbox"
class="custom-scrollbar absolute z-40 mt-1.5 max-h-60 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/30"
>
<button
v-for="(option, index) in options"
:key="`${String(option.value)}-${index}`"
type="button"
role="option"
:aria-selected="Object.is(option.value, modelValue)"
:disabled="option.disabled"
:data-option-index="index"
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
:class="[
Object.is(option.value, modelValue)
? 'bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70',
activeIndex === index && !Object.is(option.value, modelValue)
? 'bg-slate-100 dark:bg-slate-700/70'
: '',
]"
@mouseenter="activeIndex = index"
@click="choose(option)"
>
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
<svg v-if="Object.is(option.value, modelValue)" class="h-4 w-4 shrink-0 fill-none stroke-current" viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12 4 4L19 6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
</svg>
</button>
<div v-if="options.length === 0" class="px-3 py-4 text-center text-xs text-slate-400">暂无可选项</div>
</div>
</transition>
</div>
</template>
<style scoped>
.select-menu-enter-active,
.select-menu-leave-active {
transform-origin: top;
transition: opacity 120ms ease, transform 120ms ease;
}
.select-menu-enter-from,
.select-menu-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
</style>
+60
View File
@@ -0,0 +1,60 @@
<script setup>
import { useRoute } from "vue-router";
import { useAppStore } from "../stores/app";
import { visibleNavItems } from "../navigation";
import vntIcon from "../assets/vnt-icon.png";
defineEmits(["navigate"]);
const route = useRoute();
const app = useAppStore();
const items = visibleNavItems();
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-slate-900">
<div class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 px-5 dark:border-slate-800">
<img :src="vntIcon" alt="" class="h-8 w-8 shrink-0" />
<div>
<div class="text-sm font-bold tracking-wide text-slate-900 dark:text-white">VNT</div>
<div class="text-[9px] font-semibold tracking-[0.18em] text-slate-400">CONTROL CENTER</div>
</div>
</div>
<div class="mx-3 mt-4 flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 dark:border-slate-700 dark:bg-slate-800/60">
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
></span>
<div class="min-w-0">
<div class="text-[9px] font-semibold tracking-wider text-slate-400">虚拟网络</div>
<div class="mt-0.5 truncate text-xs font-medium text-slate-700 dark:text-slate-200">{{ app.headerStatusText }}</div>
</div>
</div>
<nav class="mt-4 flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-3" aria-label="主导航">
<router-link
v-for="item in items"
:key="item.to"
:to="item.to"
class="flex min-h-10 items-center gap-3 rounded-lg px-3 text-sm font-medium transition-colors"
:class="route.path === item.to
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white'"
@click="$emit('navigate')"
>
<svg class="h-[18px] w-[18px] shrink-0 fill-none stroke-current" viewBox="0 0 24 24">
<path :d="item.icon" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" />
</svg>
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="mx-3 mt-3 shrink-0 border-t border-slate-200 px-1 py-4 dark:border-slate-800">
<div class="flex items-center gap-2 text-xs text-slate-400">
<span class="h-1.5 w-1.5 rounded-full" :class="app.version ? 'bg-green-500' : 'bg-amber-400'"></span>
<span>{{ app.version ? "本地服务正常" : "正在连接服务…" }}</span>
<span class="ml-auto font-mono text-[10px]">v{{ app.version || "2.0" }}</span>
</div>
</div>
</div>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup>
import { ref } from "vue";
// NAT tooltip(/)
const tooltipState = ref({ show: false, x: 0, y: 0, info: null });
let tooltipHideTimer = null;
const showPeerTooltip = (event, peer) => {
if (!peer.nat_info) return;
if (tooltipHideTimer) {
clearTimeout(tooltipHideTimer);
tooltipHideTimer = null;
}
const rect = event.currentTarget.getBoundingClientRect();
tooltipState.value = {
show: true,
x: rect.left + rect.width / 2,
y: rect.bottom + 10,
info: peer.nat_info,
};
};
const hidePeerTooltip = () => {
tooltipHideTimer = setTimeout(() => {
tooltipState.value.show = false;
}, 100);
};
const onTooltipEnter = () => {
if (tooltipHideTimer) {
clearTimeout(tooltipHideTimer);
tooltipHideTimer = null;
}
};
const onTooltipLeave = () => {
tooltipState.value.show = false;
};
defineExpose({ showPeerTooltip, hidePeerTooltip });
</script>
<template>
<teleport to="body">
<div
v-if="tooltipState.show"
:style="{ top: tooltipState.y + 'px', left: tooltipState.x + 'px' }"
class="fixed z-[9999] mt-1 -translate-x-1/2 transform"
@mouseenter="onTooltipEnter"
@mouseleave="onTooltipLeave"
>
<div
class="w-auto min-w-[260px] max-w-[320px] rounded-lg border border-slate-200 bg-white p-4 text-left text-sm text-slate-600 shadow-2xl dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
<div
class="absolute -top-2 left-1/2 h-4 w-4 -translate-x-1/2 rotate-45 transform border-l border-t border-slate-200 bg-white dark:border-slate-600 dark:bg-slate-800"
></div>
<div class="relative z-10 mb-2 flex items-center justify-between border-b border-slate-200 pb-2 dark:border-slate-600">
<span class="text-xs font-bold uppercase text-slate-400">NAT Type</span>
<span class="badge-green border border-green-200 dark:border-green-800">{{
tooltipState.info.nat_type
}}</span>
</div>
<div
v-if="tooltipState.info.public_ips && tooltipState.info.public_ips.length > 0"
class="relative z-10 mb-3"
>
<span class="mb-1 block text-xs text-slate-400">Public IPv4:</span>
<div class="flex flex-wrap gap-1">
<span
v-for="pip in tooltipState.info.public_ips"
:key="pip"
class="rounded border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-xs tabular-nums text-slate-600 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
>{{ pip }}</span
>
</div>
</div>
<div v-if="tooltipState.info.ipv6" class="relative z-10">
<span class="mb-1 block text-xs text-slate-400">IPv6:</span>
<div
class="whitespace-normal break-all rounded border border-slate-200 bg-slate-50 p-1.5 font-mono text-xs leading-relaxed text-slate-600 dark:border-slate-700/50 dark:bg-slate-900/50 dark:text-slate-200"
>
{{ tooltipState.info.ipv6 }}
</div>
</div>
</div>
</div>
</teleport>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import { useUiStore } from "../stores/ui";
import AppModal from "./AppModal.vue";
const ui = useUiStore();
</script>
<template>
<AppModal
:show="ui.confirmState.show"
panel-class="w-full max-w-sm"
@close="ui.confirmCancel"
>
<template #header>
<h3 class="text-lg font-bold text-slate-900 flex items-center gap-2 dark:text-white">
<svg
v-if="ui.confirmState.danger"
class="w-5 h-5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{{ ui.confirmState.title }}
</h3>
</template>
<template #body>
<p class="px-6 py-5 text-sm text-slate-600 break-all dark:text-slate-300">
{{ ui.confirmState.message }}
</p>
</template>
<template #footer>
<button class="btn-ghost" @click="ui.confirmCancel">取消</button>
<button
:class="ui.confirmState.danger ? 'btn-danger' : 'btn-primary'"
@click="ui.confirmOk"
>
{{ ui.confirmState.confirmText }}
</button>
</template>
</AppModal>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup>
defineProps({
text: { type: String, default: "暂无数据" },
});
</script>
<template>
<div class="card p-12 text-center text-slate-500 dark:text-slate-400">
<svg
class="w-12 h-12 mx-auto mb-3 text-slate-300 dark:text-slate-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p>{{ text }}</p>
</div>
</template>
+165
View File
@@ -0,0 +1,165 @@
<script setup>
import { computed } from "vue";
import { useAppStore } from "../stores/app";
import { useUiStore } from "../stores/ui";
const props = defineProps({
inst: { type: Object, required: true },
// ()
selectable: { type: Boolean, default: false },
});
const app = useAppStore();
const ui = useUiStore();
const info = computed(() => app.infoOf(props.inst.file_name));
const loading = computed(() => !!app.loadingMap[props.inst.file_name]);
// :/
const stopping = computed(() => !!app.stoppingMap[props.inst.file_name]);
// ,
const displayName = computed(() => props.inst.config_name || props.inst.file_name);
const statusBadgeClass = (status) =>
stopping.value
? "badge-yellow"
: status === "running"
? "badge-green"
: status === "starting"
? "badge-blue"
: "badge-gray";
const statusText = (status) =>
stopping.value
? "停止中"
: status === "running"
? "运行中"
: status === "starting"
? "启动中"
: "已停止";
const select = () => {
if (props.selectable) app.selectedInstance = props.inst.file_name;
};
const confirmStop = async () => {
const ok = await ui.confirm({
title: "停止组网",
message: `确定要停止 ${displayName.value} 吗?`,
danger: true,
confirmText: "停止",
});
if (ok) app.stopVnt(props.inst.file_name);
};
const confirmRestart = async () => {
const ok = await ui.confirm({
title: "重启组网",
message: `确定要重启 ${displayName.value} 吗?`,
});
if (ok) app.restartVnt(props.inst.file_name);
};
const confirmDismiss = async () => {
const ok = await ui.confirm({
title: "移除实例",
message: `确定要移除已停止的实例 ${displayName.value} 吗?`,
danger: true,
confirmText: "移除",
});
if (ok) app.dismissInstance(props.inst.file_name);
};
</script>
<template>
<div
class="card"
:class="[
selectable ? 'cursor-pointer' : '',
selectable && app.selectedInstance === inst.file_name
? 'ring-2 ring-indigo-500 dark:ring-indigo-400'
: '',
]"
@click="select"
>
<div class="flex items-center justify-between gap-2">
<h3 class="truncate text-base font-bold text-slate-900 dark:text-white" :title="inst.file_name">
{{ displayName }}
</h3>
<div class="flex shrink-0 items-center gap-1.5">
<span
v-if="info.config_changed"
class="badge-yellow"
title="配置文件在启动后被修改,重启实例后生效"
>
配置发生变化
</span>
<span :class="[statusBadgeClass(inst.status), stopping ? 'animate-pulse' : '']">{{
statusText(inst.status)
}}</span>
</div>
</div>
<div class="mt-2 text-sm">
<span class="muted">虚拟 IP:</span>
<span class="ml-1 font-mono tabular-nums text-indigo-600 dark:text-indigo-400">{{
info.ip || "-"
}}</span>
</div>
<div class="mt-3 flex gap-4 text-xs muted">
<span>
在线
<span class="font-bold tabular-nums text-blue-600 dark:text-blue-400">{{
info.online_client_num || 0
}}</span>
</span>
<span>
直连
<span class="font-bold tabular-nums text-green-600 dark:text-green-400">{{
info.direct_client_num || 0
}}</span>
</span>
<span>
离线
<span class="font-bold tabular-nums text-slate-400">{{ info.offline_client_num || 0 }}</span>
</span>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-2">
<template v-if="stopping">
<span class="flex items-center gap-1.5 text-xs muted">
<span class="inline-block animate-spin"></span>
正在停止请稍候...
</span>
</template>
<template v-else>
<button
v-if="inst.status === 'running' || inst.status === 'stopped'"
class="btn-primary btn-sm"
:disabled="loading"
@click.stop="confirmRestart"
>
<span v-if="loading" class="animate-spin"></span>
{{ inst.status === "stopped" ? "重新启动" : "重启" }}
</button>
<button
v-if="inst.status !== 'stopped'"
class="btn-danger btn-sm"
:disabled="loading"
@click.stop="confirmStop"
>
<span v-if="loading" class="animate-spin"></span>
停止
</button>
<button
v-if="inst.status === 'stopped'"
class="btn-ghost btn-sm"
:disabled="loading"
@click.stop="confirmDismiss"
>
<span v-if="loading" class="animate-spin"></span>
移除
</button>
</template>
</div>
</div>
</template>
+136
View File
@@ -0,0 +1,136 @@
<script setup>
import { ref, watch, onMounted, nextTick } from "vue";
import { formatSpeed, niceNumber } from "../utils/format";
const props = defineProps({
history: { type: Object, required: true }, // { tx: [], rx: [] }
size: { type: Number, default: 60 }, //
});
const canvasRef = ref(null);
const maxLabel = ref("");
const draw = () => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const txArr = props.history ? props.history.tx : [];
const rxArr = props.history ? props.history.rx : [];
const HISTORY_SIZE = props.size;
//
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0) return;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = rect.width;
const h = rect.height;
const isDark = document.documentElement.classList.contains("dark");
const colors = isDark
? { bg: "#0c1222", grid: "rgba(71, 85, 105, 0.3)", rx: "#60a5fa", rxFill: "rgba(96, 165, 250, 0.15)", tx: "#4ade80", txFill: "rgba(74, 222, 128, 0.15)" }
: { bg: "#f8fafc", grid: "rgba(148, 163, 184, 0.35)", rx: "#3b82f6", rxFill: "rgba(59, 130, 246, 0.12)", tx: "#22c55e", txFill: "rgba(34, 197, 94, 0.12)" };
const padTop = 8,
padBottom = 4,
padLeft = 0,
padRight = 0;
const chartW = w - padLeft - padRight;
const chartH = h - padTop - padBottom;
//
ctx.fillStyle = colors.bg;
ctx.fillRect(0, 0, w, h);
// Y
const allValues = [...txArr, ...rxArr];
let maxVal = allValues.length > 0 ? Math.max(...allValues) : 0;
if (maxVal < 1024) maxVal = 1024; // 1KB
const niceMax = niceNumber(maxVal);
maxLabel.value = "峰值: " + formatSpeed(niceMax);
// 线
const gridLines = 4;
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
for (let i = 0; i <= gridLines; i++) {
const y = padTop + (chartH / gridLines) * i;
ctx.beginPath();
ctx.moveTo(padLeft, y);
ctx.lineTo(padLeft + chartW, y);
ctx.stroke();
}
// 线
const vLines = 6;
for (let i = 0; i <= vLines; i++) {
const x = padLeft + (chartW / vLines) * i;
ctx.beginPath();
ctx.moveTo(x, padTop);
ctx.lineTo(x, padTop + chartH);
ctx.stroke();
}
// 线
const drawLine = (data, strokeColor, fillColor) => {
if (data.length < 2) return;
const step = chartW / (HISTORY_SIZE - 1);
const offset = HISTORY_SIZE - data.length;
//
ctx.beginPath();
ctx.moveTo(padLeft + offset * step, padTop + chartH);
for (let i = 0; i < data.length; i++) {
const x = padLeft + (offset + i) * step;
const y = padTop + chartH - (data[i] / niceMax) * chartH;
if (i === 0) ctx.lineTo(x, y);
else ctx.lineTo(x, y);
}
ctx.lineTo(padLeft + (offset + data.length - 1) * step, padTop + chartH);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// 线
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = padLeft + (offset + i) * step;
const y = padTop + chartH - (data[i] / niceMax) * chartH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1.5;
ctx.stroke();
};
drawLine(rxArr, colors.rx, colors.rxFill);
drawLine(txArr, colors.tx, colors.txFill);
};
onMounted(() => nextTick(draw));
// ( push/shift,)
watch(
() => [props.history?.tx?.length, props.history?.rx?.length, props.history?.tx?.at(-1), props.history?.rx?.at(-1)],
() => nextTick(draw),
);
defineExpose({ draw });
</script>
<template>
<div>
<div class="flex items-center gap-4 mb-2 text-xs text-slate-400">
<span class="flex items-center"
><span class="inline-block w-3 h-0.5 bg-green-400 mr-1"></span>上传速度</span
>
<span class="flex items-center"
><span class="inline-block w-3 h-0.5 bg-blue-400 mr-1"></span>下载速度</span
>
<span class="ml-auto">{{ maxLabel }}</span>
</div>
<canvas ref="canvasRef" class="rounded w-full block h-[150px]"></canvas>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup>
import { ref, computed, watch } from "vue";
import { useAppStore } from "../stores/app";
import { useUiStore } from "../stores/ui";
import AppSelect from "./AppSelect.vue";
// : + ,
const app = useAppStore();
const ui = useUiStore();
const localSelectedConfig = ref("");
// ()
const availableConfigs = computed(() =>
app.configList.filter(
(cfg) => !app.instanceList.some((inst) => inst.file_name === cfg.file_name),
),
);
const configOptions = computed(() =>
availableConfigs.value.map((cfg) => ({
value: cfg.file_name,
label: cfg.config_name || cfg.file_name,
})),
);
// ;()
watch(
availableConfigs,
(list) => {
if (!list.some((cfg) => cfg.file_name === localSelectedConfig.value)) {
localSelectedConfig.value = list.length ? list[0].file_name : "";
}
},
{ immediate: true },
);
const handleStart = () => {
if (!localSelectedConfig.value) {
ui.toast.error("请先选择一个配置");
return;
}
app.startVnt(localSelectedConfig.value);
};
</script>
<template>
<div class="card">
<h2 class="mb-4 text-base font-bold text-slate-900 dark:text-white">启动组网</h2>
<div v-if="app.configList.length === 0" class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm muted">还没有任何配置先创建一个组网配置吧</p>
<router-link to="/config" class="btn-primary btn-sm">去新建配置</router-link>
</div>
<div v-else-if="availableConfigs.length === 0" class="text-sm muted">
所有配置均已启动
</div>
<div v-else class="flex flex-col gap-3 sm:flex-row sm:items-end">
<div class="flex-1">
<label class="mb-1.5 block text-xs font-medium muted">选择配置</label>
<AppSelect v-model="localSelectedConfig" :options="configOptions" placeholder="请选择配置…" aria-label="选择配置" />
</div>
<button
class="btn-primary px-8"
:disabled="!localSelectedConfig || !!app.loadingMap[localSelectedConfig]"
@click="handleStart"
>
<span v-if="app.loadingMap[localSelectedConfig]" class="animate-spin"></span>
启动
</button>
</div>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup>
defineProps({
status: { type: String, default: "stopped" },
size: { type: String, default: "w-2.5 h-2.5" },
});
const dotClass = (status) =>
status === "running"
? "bg-green-500"
: status === "starting"
? "bg-blue-500 animate-pulse"
: "bg-slate-400 dark:bg-slate-500";
</script>
<template>
<span class="rounded-full inline-block shrink-0" :class="[size, dotClass(status)]" />
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup>
import { useUiStore } from "../stores/ui";
const ui = useUiStore();
// +
const barClass = (type) =>
type === "success" ? "bg-green-500" : type === "error" ? "bg-red-500" : "bg-indigo-500";
const iconClass = (type) =>
type === "success"
? "text-green-500"
: type === "error"
? "text-red-500"
: "text-indigo-500";
const iconPath = (type) =>
type === "success"
? "M5 13l4 4L19 7"
: type === "error"
? "M6 18L18 6M6 6l12 12"
: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
</script>
<template>
<teleport to="body">
<div class="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col items-end gap-2">
<transition-group name="toast">
<div
v-for="t in ui.toasts"
:key="t.id"
class="pointer-events-auto flex max-w-sm items-stretch overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<span class="w-1 shrink-0" :class="barClass(t.type)"></span>
<div class="flex items-center gap-2 px-4 py-2.5">
<svg class="h-4 w-4 shrink-0" :class="iconClass(t.type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPath(t.type)" />
</svg>
<span class="break-all text-sm text-slate-700 dark:text-slate-200">{{ t.message }}</span>
</div>
</div>
</transition-group>
</div>
</teleport>
</template>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import vntIcon from "./assets/vnt-icon.png";
import "./style.css";
const favicon = document.querySelector('link[rel~="icon"]') || document.createElement("link");
favicon.rel = "icon";
favicon.type = "image/png";
favicon.href = vntIcon;
document.head.appendChild(favicon);
createApp(App).use(createPinia()).use(router).mount("#app");
+48
View File
@@ -0,0 +1,48 @@
export const navItems = [
{
to: "/",
label: "网络总览",
shortLabel: "总览",
subtitle: "组网状态与实例管理",
icon: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",
},
{
to: "/peers",
label: "在线设备",
shortLabel: "设备",
subtitle: "查看网络中的对端设备",
icon: "M15 19a6 6 0 0 0-12 0m18 0a5 5 0 0 0-5-5m-7-3a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm8 0a3 3 0 1 0 0-6",
},
{
to: "/routes",
label: "路由表",
shortLabel: "路由",
subtitle: "虚拟网络路由信息",
icon: "M6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm12 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM8 6h6a4 4 0 0 1 4 4v2m-3-3 3 3 3-3M16 18h-6a4 4 0 0 1-4-4v-2",
},
{
to: "/config",
label: "组网配置",
shortLabel: "配置",
subtitle: "管理组网配置文件",
icon: "M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm7.4-.5a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3A1.7 1.7 0 0 0 14 21v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14h-.2v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6-1Z",
},
{
to: "/web-access",
label: "Web 访问",
shortLabel: "Web",
subtitle: "远程访问设置",
desktopOnly: true,
icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0c-2.2-2.5 3.3-5.5 3.3-9S14.2 5.5 12 3m0 18c-2.2-2.5-3.3-5.5-3.3-9S9.8 5.5 12 3M3.5 9h17m-17 6h17",
},
{
to: "/about",
label: "关于",
shortLabel: "关于",
subtitle: "版本与更新",
icon: "M12 17v-6m0-4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z",
},
];
export const visibleNavItems = () =>
navItems.filter((item) => !item.desktopOnly || globalThis.__VNT_DESKTOP__);
+24
View File
@@ -0,0 +1,24 @@
import { createRouter, createWebHashHistory } from "vue-router";
import DashboardView from "../views/DashboardView.vue";
import ConfigView from "../views/ConfigView.vue";
import PeersView from "../views/PeersView.vue";
import RoutesView from "../views/RoutesView.vue";
import WebAccessView from "../views/WebAccessView.vue";
import AboutView from "../views/AboutView.vue";
const routes = [
{ path: "/", component: DashboardView },
// 兼容旧路由:实例管理已并入网络总览
{ path: "/instances", redirect: "/" },
{ path: "/general", redirect: "/" },
{ path: "/config", component: ConfigView },
{ path: "/peers", component: PeersView },
{ path: "/routes", component: RoutesView },
{ path: "/web-access", component: WebAccessView },
{ path: "/about", component: AboutView },
];
export default createRouter({
history: createWebHashHistory(),
routes,
});
+272
View File
@@ -0,0 +1,272 @@
import { defineStore } from "pinia";
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from "vue";
import {
getInstances,
getInstanceInfo,
getConfigList,
getVersion,
startVntApi,
stopVntApi,
restartVntApi,
deleteInstance,
} from "../api";
import { useUiStore } from "./ui";
import { useStartLogStore } from "./startLog";
import { getAccessToken, isDesktop } from "../auth";
export const useAppStore = defineStore("app", () => {
const ui = useUiStore();
const startLog = useStartLogStore();
// 多实例状态:key=file_name, value=该实例 info
const instances = ref({});
const instanceList = ref([]);
const selectedInstance = ref(null);
const configList = ref([]);
const loadingMap = ref({});
// 停止中的实例:key=file_name;点击停止后置位,实例从列表消失或变为 stopped 时清除
const stoppingMap = ref({});
// 页面可见性
const isPageVisible = ref(!document.hidden);
const visibilityHandler = () => {
isPageVisible.value = !document.hidden;
};
let infoTimer = null;
const runningCount = computed(
() => instanceList.value.filter((i) => i.status === "running").length,
);
const startingCount = computed(
() => instanceList.value.filter((i) => i.status === "starting").length,
);
const headerStatusText = computed(() => {
if (runningCount.value > 0) return `运行中 x${runningCount.value}`;
if (startingCount.value > 0) return "启动中...";
return "未启动";
});
const selectedInfo = computed(() =>
selectedInstance.value ? instances.value[selectedInstance.value] || null : null,
);
const selectedConfigName = computed(() => {
if (!selectedInstance.value) return "";
const inst = instanceList.value.find(
(i) => i.file_name === selectedInstance.value,
);
return inst ? inst.config_name || inst.file_name : selectedInstance.value;
});
// 客户端版本号,来自 /api/version,与组网状态无关
const version = ref("");
const fetchVersion = async () => {
try {
version.value = (await getVersion()) || "";
} catch (e) {
console.error("Fetch version error", e);
}
};
const isServerConnected = computed(
() =>
!!(
selectedInfo.value &&
selectedInfo.value.server_info &&
selectedInfo.value.server_info.some((s) => s.connected)
),
);
const serverStatusText = computed(() => {
const si = selectedInfo.value;
if (!si || !si.server_info || !si.server_info.length) return "未配置服务器";
return `${si.server_info.filter((s) => s.connected).length} / ${si.server_info.length} 已连接`;
});
const infoOf = (fileName) => instances.value[fileName] || {};
const fetchInstanceInfo = async (fileName) => {
try {
instances.value[fileName] = await getInstanceInfo(fileName);
} catch (e) {
console.error("Fetch info error", e);
}
};
const fetchInstances = async () => {
try {
const list = (await getInstances()) || [];
instanceList.value = list;
// 清理已消失实例的 info 缓存
for (const key of Object.keys(instances.value)) {
if (!list.some((i) => i.file_name === key)) {
delete instances.value[key];
}
}
// 停止已生效(实例消失或进入 stopped)时清除停止中标记
for (const key of Object.keys(stoppingMap.value)) {
const inst = list.find((i) => i.file_name === key);
if (!inst || inst.status === "stopped") {
delete stoppingMap.value[key];
}
}
// 默认选中逻辑:当前选中失效时优先第一个 running,否则第一个,否则 null
if (
!selectedInstance.value ||
!list.some((i) => i.file_name === selectedInstance.value)
) {
const running = list.find((i) => i.status === "running");
selectedInstance.value = running
? running.file_name
: list.length > 0
? list[0].file_name
: null;
}
// 拉取 running 实例的详情
for (const inst of list) {
if (inst.status === "running") {
fetchInstanceInfo(inst.file_name);
}
}
} catch (e) {
console.error("Fetch instances error", e);
}
};
const fetchConfigList = async () => {
try {
configList.value = (await getConfigList()) || [];
} catch (e) {
console.error("Fetch list error", e);
}
};
const startVnt = async (fileName) => {
if (!fileName) {
ui.toast.error("请先选择一个配置");
return;
}
if (loadingMap.value[fileName]) return;
loadingMap.value[fileName] = true;
try {
await startVntApi(fileName);
startLog.openStartLog(fileName);
fetchInstances();
} catch (e) {
ui.toast.error("启动失败: " + e.message);
} finally {
loadingMap.value[fileName] = false;
}
};
const stopVnt = async (fileName) => {
if (!fileName || loadingMap.value[fileName]) return;
loadingMap.value[fileName] = true;
stoppingMap.value[fileName] = true;
try {
await stopVntApi(fileName);
ui.toast.success("已停止");
if (startLog.logFileName === fileName) {
startLog.stopPolling();
startLog.showStartLog = false;
}
fetchInstances();
} catch (e) {
ui.toast.error("停止失败: " + e.message);
console.error(e);
// 停止失败,恢复可操作状态
delete stoppingMap.value[fileName];
} finally {
loadingMap.value[fileName] = false;
}
};
const restartVnt = async (fileName) => {
if (!fileName || loadingMap.value[fileName]) return;
loadingMap.value[fileName] = true;
try {
await restartVntApi(fileName);
startLog.openStartLog(fileName);
fetchInstances();
} catch (e) {
ui.toast.error("重启失败: " + e.message);
} finally {
loadingMap.value[fileName] = false;
}
};
// 移除已停止(启动失败残留)的实例条目
const dismissInstance = async (fileName) => {
if (!fileName || loadingMap.value[fileName]) return;
loadingMap.value[fileName] = true;
try {
await deleteInstance(fileName);
ui.toast.success("已移除");
if (startLog.logFileName === fileName) {
startLog.stopPolling();
startLog.showStartLog = false;
}
if (selectedInstance.value === fileName) {
selectedInstance.value = null;
}
fetchInstances();
} catch (e) {
ui.toast.error("移除失败: " + e.message);
} finally {
loadingMap.value[fileName] = false;
}
};
// 注入依赖,供启动日志 store 刷新实例与解析配置名
startLog.bindApp({ fetchInstances, instanceList, configList });
const init = async () => {
if (!isDesktop && !getAccessToken()) return;
document.addEventListener("visibilitychange", visibilityHandler);
fetchVersion();
await fetchInstances();
fetchConfigList();
// 页面加载时若有正在启动的实例,恢复其日志弹窗
const starting = instanceList.value.find((i) => i.status === "starting");
if (starting) startLog.openStartLog(starting.file_name);
// 全局 3s 轮询实例列表及 running 实例详情(仅页面可见)
infoTimer = setInterval(() => {
if (isPageVisible.value) fetchInstances();
}, 3000);
};
const destroy = () => {
document.removeEventListener("visibilitychange", visibilityHandler);
startLog.stopPolling();
if (infoTimer) clearInterval(infoTimer);
};
// store 在组件 setup 中被使用时自动挂接生命周期
if (getCurrentInstance()) {
onMounted(init);
onUnmounted(destroy);
}
return {
instances,
instanceList,
selectedInstance,
configList,
loadingMap,
stoppingMap,
isPageVisible,
runningCount,
startingCount,
headerStatusText,
selectedInfo,
selectedConfigName,
version,
isServerConnected,
serverStatusText,
infoOf,
fetchInstances,
fetchInstanceInfo,
fetchConfigList,
startVnt,
stopVnt,
restartVnt,
dismissInstance,
};
});
+116
View File
@@ -0,0 +1,116 @@
import { defineStore } from "pinia";
import { ref, computed, nextTick } from "vue";
import { getStartStatus, stopVntApi } from "../api";
// 启动日志弹窗状态
export const useStartLogStore = defineStore("startLog", () => {
const showStartLog = ref(false);
const startLogs = ref([]);
const startStatus = ref("stopped");
const logFileName = ref(null);
const logContainer = ref(null);
let statusInterval = null;
// 由 app store 注入,避免循环依赖
let fetchInstancesFn = null;
let instanceListRef = null;
let configListRef = null;
const bindApp = ({ fetchInstances, instanceList, configList }) => {
fetchInstancesFn = fetchInstances;
instanceListRef = instanceList;
configListRef = configList;
};
const logConfigName = computed(() => {
if (!logFileName.value) return "";
const inst = (instanceListRef?.value || []).find(
(i) => i.file_name === logFileName.value,
);
if (inst) return inst.config_name || inst.file_name;
const cfg = (configListRef?.value || []).find(
(c) => c.file_name === logFileName.value,
);
return cfg ? cfg.config_name || cfg.file_name : logFileName.value;
});
const pollStartStatus = async () => {
if (!logFileName.value) return;
try {
const data = await getStartStatus(logFileName.value);
startLogs.value = data.logs || [];
startStatus.value = data.status;
nextTick(() => {
if (logContainer.value)
logContainer.value.scrollTop = logContainer.value.scrollHeight;
});
if (startStatus.value === "running") {
stopPolling();
fetchInstancesFn && fetchInstancesFn();
showStartLog.value = false;
} else if (startStatus.value === "stopped" && startLogs.value.length > 0) {
stopPolling();
fetchInstancesFn && fetchInstancesFn();
}
} catch (e) {
console.error(e);
}
};
const stopPolling = () => {
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
}
};
const startPolling = () => {
stopPolling();
statusInterval = setInterval(pollStartStatus, 1000);
pollStartStatus();
};
const openStartLog = (fileName) => {
logFileName.value = fileName;
startLogs.value = [];
startStatus.value = "starting";
showStartLog.value = true;
startPolling();
};
// 取消组网:停止轮询并 POST /api/stop
const cancelStart = async () => {
stopPolling();
const fileName = logFileName.value;
if (fileName) {
try {
await stopVntApi(fileName);
startLogs.value.push("启动已手动取消");
} catch (e) {
// 忽略取消时的网络错误
}
}
startStatus.value = "stopped";
fetchInstancesFn && fetchInstancesFn();
};
const close = () => {
showStartLog.value = false;
};
return {
showStartLog,
startLogs,
startStatus,
logFileName,
logContainer,
logConfigName,
bindApp,
openStartLog,
pollStartStatus,
startPolling,
stopPolling,
cancelStart,
close,
};
});
+64
View File
@@ -0,0 +1,64 @@
import { defineStore } from "pinia";
import { reactive, ref } from "vue";
let toastId = 0;
export const useUiStore = defineStore("ui", () => {
// toast 队列
const toasts = ref([]);
const pushToast = (type, message, duration = 3000) => {
const id = ++toastId;
toasts.value.push({ id, type, message });
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id);
}, duration);
};
const toast = {
success: (msg) => pushToast("success", msg),
error: (msg) => pushToast("error", msg, 4500),
info: (msg) => pushToast("info", msg),
};
// confirm 弹窗:返回 Promise<boolean>
const confirmState = reactive({
show: false,
title: "",
message: "",
danger: false,
confirmText: "确定",
resolve: null,
});
const confirm = ({ title = "确认操作", message = "", danger = false, confirmText = "确定" } = {}) =>
new Promise((resolve) => {
confirmState.show = true;
confirmState.title = title;
confirmState.message = message;
confirmState.danger = danger;
confirmState.confirmText = confirmText;
confirmState.resolve = resolve;
});
const confirmOk = () => {
confirmState.show = false;
confirmState.resolve && confirmState.resolve(true);
confirmState.resolve = null;
};
const confirmCancel = () => {
confirmState.show = false;
confirmState.resolve && confirmState.resolve(false);
confirmState.resolve = null;
};
return {
toasts,
toast,
confirmState,
confirm,
confirmOk,
confirmCancel,
};
});
+278
View File
@@ -0,0 +1,278 @@
@import "tailwindcss";
@source "./**/*.{vue,js}";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* 全局唯一主色 */
--color-accent: var(--color-indigo-600);
--color-accent-hover: var(--color-indigo-500);
}
body {
@apply bg-slate-50 text-slate-700 antialiased dark:bg-slate-950 dark:text-slate-200;
}
html,
body,
#app {
height: 100%;
overflow: hidden;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply rounded bg-slate-300 dark:bg-slate-600;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-slate-400 dark:bg-slate-500;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--color-slate-300) transparent;
}
.dark .custom-scrollbar {
scrollbar-color: var(--color-slate-600) transparent;
}
/* 路由切换动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 实例卡片列表:停止/移除后渐隐收缩消失 */
.card-list-enter-active,
.card-list-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.card-list-enter-from,
.card-list-leave-to {
opacity: 0;
transform: scale(0.96);
}
/* 弹窗动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .modal-panel,
.modal-leave-active .modal-panel {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-panel,
.modal-leave-to .modal-panel {
transform: scale(0.96);
opacity: 0;
}
/* Toast 动画 */
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(20px);
}
/* 移动导航动画 */
.navdrop-enter-active,
.navdrop-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.navdrop-enter-from,
.navdrop-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* 移动端侧栏抽屉 */
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active .drawer-panel,
.drawer-leave-active .drawer-panel {
transition: transform 0.2s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from .drawer-panel,
.drawer-leave-to .drawer-panel {
transform: translateX(-100%);
}
@utility btn {
@apply inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-150 active:scale-95 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40;
}
@utility badge {
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
}
@layer components {
.btn-primary {
@apply btn bg-indigo-600 text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400;
}
.btn-ghost {
@apply btn border border-slate-300 bg-white text-slate-700 shadow-sm hover:bg-slate-50
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700;
}
.btn-danger {
@apply btn bg-red-600 text-white shadow-sm hover:bg-red-500;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
.card {
@apply rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow duration-200
hover:shadow-md dark:border-slate-700/80 dark:bg-slate-900;
}
.input {
@apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900
placeholder-slate-400 transition-colors
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/25
disabled:opacity-50 disabled:cursor-not-allowed
dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder-slate-500;
}
/* 语义徽章:浅色底深色字,dark 下深底浅字 */
.badge-green {
@apply badge bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300;
}
.badge-red {
@apply badge bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300;
}
.badge-yellow {
@apply badge bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300;
}
.badge-blue {
@apply badge bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300;
}
.badge-purple {
@apply badge bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300;
}
.badge-gray {
@apply badge bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-300;
}
.table {
@apply min-w-full divide-y divide-slate-200 dark:divide-slate-700;
}
.table thead {
@apply bg-slate-50 dark:bg-slate-800/60;
}
.table thead th {
@apply px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 whitespace-nowrap
dark:text-slate-400;
}
.table tbody {
@apply divide-y divide-slate-200 bg-white dark:divide-slate-700 dark:bg-transparent;
}
.table tbody td {
@apply px-4 py-3 text-sm text-slate-600 whitespace-nowrap dark:text-slate-300;
}
/* 表格内静态 tooltip */
.tooltip {
@apply relative inline-flex items-center justify-center;
}
.tooltip .tooltip-text {
@apply invisible absolute bottom-[125%] left-1/2 z-10 w-36 -translate-x-1/2 rounded-md
border border-slate-200 bg-white px-2 py-1.5 text-center text-xs text-slate-600 shadow-lg
opacity-0 transition-opacity duration-300 pointer-events-none
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200;
}
.tooltip:hover .tooltip-text {
@apply visible opacity-100;
}
/* 页面标题区 */
.page-title {
@apply text-2xl font-bold text-slate-900 dark:text-white;
}
.page-subtitle {
@apply mt-1 text-sm text-slate-500 dark:text-slate-400;
}
/* 页面名称由统一外壳标题栏展示,视图只保留自身操作按钮。 */
.page-title,
.page-subtitle {
display: none;
}
/* 弱文本 */
.muted {
@apply text-slate-500 dark:text-slate-400;
}
}
+55
View File
@@ -0,0 +1,55 @@
// 格式化时间
export const formatTime = (timestamp) => {
if (!timestamp) return "-";
const date = new Date(timestamp * 1000);
const pad = (n) => (n < 10 ? "0" + n : n);
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
// 格式化字节数
export const formatBytes = (bytes) => {
if (bytes === 0 || bytes === undefined || bytes === null) return "0B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
};
// 格式化速度(字节/秒)
export const formatSpeed = (bytesPerSecond) => {
if (
bytesPerSecond === 0 ||
bytesPerSecond === undefined ||
bytesPerSecond === null
)
return "0B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let i = 0;
let value = bytesPerSecond;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
};
// 将数值取整到适合的刻度
export const niceNumber = (val) => {
const units = [
1024, // 1KB
10 * 1024, // 10KB
100 * 1024, // 100KB
1024 * 1024, // 1MB
10 * 1024 * 1024, // 10MB
100 * 1024 * 1024, // 100MB
1024 * 1024 * 1024, // 1GB
];
for (const u of units) {
if (val <= u) return u;
}
return Math.ceil(val / (1024 * 1024 * 1024)) * 1024 * 1024 * 1024;
};
+23
View File
@@ -0,0 +1,23 @@
export const NETWORK_QR_TYPE = "vnt-network";
export const NETWORK_QR_VERSION = 1;
export const buildNetworkQrPayload = (form) => {
const networkCode = String(form.network_code || "").trim();
const servers = (form.server || []).map((server) => String(server).trim()).filter(Boolean);
const mtu = Number(form.mtu || 1380);
if (!networkCode) throw new Error("组网编号不能为空");
if (servers.length === 0) throw new Error("服务器地址不能为空");
if (!Number.isInteger(mtu) || mtu < 576 || mtu > 9000) {
throw new Error("MTU 必须在 576-9000 之间");
}
return {
type: NETWORK_QR_TYPE,
version: NETWORK_QR_VERSION,
network_code: networkCode,
server: servers,
mtu,
password: String(form.password || ""),
};
};
+365
View File
@@ -0,0 +1,365 @@
// 从旧 index.html 原样迁移的 TOML <-> 表单双向解析逻辑
export const emptyFormData = () => ({
config_name: "",
network_code: "",
server: [""],
ip: "",
mtu: null,
rtx: false,
fec: false,
compress: false,
no_punch: false,
input: [],
output: [],
no_nat: false,
device_mode: "tun",
port_mapping: [],
allow_mapping: false,
device_name: "",
device_id: "",
tun_name: "",
outbound_interface: "",
password: "",
cert_mode: "skip",
fingerprint: "",
udp_stun: [],
tcp_stun: [],
tunnel_port: null,
});
// 从TOML解析到表单
export const parseTomlToForm = (toml) => {
const data = emptyFormData();
const lines = toml.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.includes("config_name")) {
const match = trimmed.match(/config_name\s*=\s*"([^"]*)"/);
if (match) data.config_name = match[1];
} else if (trimmed.includes("network_code")) {
const match = trimmed.match(/network_code\s*=\s*"([^"]*)"/);
if (match) data.network_code = match[1];
} else if (trimmed.startsWith("server")) {
const match = trimmed.match(/server\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.server = items.map((s) => s.replace(/"/g, ""));
}
} else if (trimmed.includes("ip =")) {
const match = trimmed.match(/ip\s*=\s*"([^"]*)"/);
if (match) data.ip = match[1];
} else if (trimmed.includes("mtu =")) {
const match = trimmed.match(/mtu\s*=\s*(\d+)/);
if (match) data.mtu = parseInt(match[1]);
} else if (trimmed.match(/^rtx\s*=/)) {
data.rtx = trimmed.includes("true");
} else if (trimmed.match(/^fec\s*=/)) {
data.fec = trimmed.includes("true");
} else if (trimmed.match(/^compress\s*=/)) {
data.compress = trimmed.includes("true");
} else if (trimmed.match(/^no_punch\s*=/)) {
data.no_punch = trimmed.includes("true");
} else if (trimmed.startsWith("input")) {
const match = trimmed.match(/input\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.input = items.map((s) => s.replace(/"/g, ""));
}
} else if (trimmed.startsWith("output")) {
const match = trimmed.match(/output\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.output = items.map((s) => s.replace(/"/g, ""));
}
} else if (trimmed.match(/^no_nat\s*=/)) {
data.no_nat = trimmed.includes("true");
} else if (trimmed.match(/^no_tun\s*=/)) {
throw new Error('配置项 no_tun 已移除,请改用 device_mode = "no|tun|tap"');
} else if (trimmed.match(/^device_mode\s*=/)) {
const match = trimmed.match(/device_mode\s*=\s*"([^"]*)"/);
if (!match || !["no", "tun", "tap"].includes(match[1])) {
throw new Error('device_mode 必须是 "no"、"tun" 或 "tap"');
}
data.device_mode = match[1];
} else if (trimmed.startsWith("port_mapping")) {
const match = trimmed.match(/port_mapping\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.port_mapping = items.map((s) => s.replace(/"/g, ""));
}
} else if (trimmed.match(/^allow_mapping\s*=/)) {
data.allow_mapping = trimmed.includes("true");
} else if (trimmed.includes("device_name")) {
const match = trimmed.match(/device_name\s*=\s*"([^"]*)"/);
if (match) data.device_name = match[1];
} else if (trimmed.includes("device_id")) {
const match = trimmed.match(/device_id\s*=\s*"([^"]*)"/);
if (match) data.device_id = match[1];
} else if (trimmed.includes("tun_name")) {
const match = trimmed.match(/tun_name\s*=\s*"([^"]*)"/);
if (match) data.tun_name = match[1];
} else if (trimmed.includes("outbound_interface")) {
const match = trimmed.match(/outbound_interface\s*=\s*"([^"]*)"/);
if (match) data.outbound_interface = match[1];
} else if (trimmed.includes("password =")) {
const match = trimmed.match(/password\s*=\s*"([^"]*)"/);
if (match) data.password = match[1];
} else if (trimmed.includes("cert_mode")) {
const match = trimmed.match(/cert_mode\s*=\s*"([^"]*)"/);
if (match) {
const value = match[1];
if (value.startsWith("finger:")) {
data.cert_mode = "finger";
data.fingerprint = value.substring(7); // 去掉 "finger:" 前缀
} else {
data.cert_mode = value;
}
}
} else if (trimmed.startsWith("udp_stun")) {
const match = trimmed.match(/udp_stun\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.udp_stun = items.map((s) => s.replace(/"/g, ""));
}
} else if (trimmed.startsWith("tcp_stun")) {
const match = trimmed.match(/tcp_stun\s*=\s*\[(.*)\]/);
if (match) {
const items = match[1].match(/"([^"]*)"/g);
if (items) data.tcp_stun = items.map((s) => s.replace(/"/g, ""));
}
}
}
return data;
};
// 从表单生成TOML
export const formToToml = (formData) => {
let toml = "";
if (formData.config_name) {
toml += `# 配置名称\nconfig_name = "${formData.config_name}"\n`;
}
toml += "\n# --- 网络配置 ---\n";
toml += "# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)\n";
toml += `network_code = "${formData.network_code}"\n\n`;
const servers = formData.server.filter((s) => s.trim());
if (servers.length > 0) {
toml += "# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)\n";
toml += "# dynamic 协议使用dns txt解析记录值\n";
toml += `server = [${servers.map((s) => `"${s}"`).join(", ")}]\n`;
}
if (formData.ip) {
toml += "\n# 自定义虚拟 IP (可选)\n";
toml += `ip = "${formData.ip}"\n`;
}
if (formData.rtx) {
toml += "\n# 是否启用quic优化传输 (默认 false)\n";
toml += "# 开启后传输过程几乎不会丢包,但是延迟可能会有波动\n";
toml += "rtx = true\n";
}
if (formData.fec) {
toml += "\n# 是否启用 FEC 前向纠错 (默认 false)\n";
toml += "# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能\n";
toml += "fec = true\n";
}
if (formData.no_punch) {
toml += "\n# 是否关闭 P2P 打洞 (默认 false)\n";
toml += "no_punch = true\n";
}
if (formData.compress) {
toml += "\n# 是否启用 LZ4 压缩 (默认 false)\n";
toml += "compress = true\n";
}
const inputs = formData.input.filter((s) => s.trim());
if (inputs.length > 0) {
toml += "\n# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点\n";
toml += "# 例如192.168.0.0/24,10.26.0.2 表示将192.168.0.0/24网段的数据转发到10.26.0.2\n";
toml += `input = [${inputs.map((s) => `"${s}"`).join(", ")}]\n`;
}
const outputs = formData.output.filter((s) => s.trim());
if (outputs.length > 0) {
toml += "\n# 出栈允许网段,用于点对网,允许指定网段的转发\n";
toml += `output = [${outputs.map((s) => `"${s}"`).join(", ")}]\n`;
}
if (formData.no_nat) {
toml += "\n# 是否关闭内置子网NAT,关闭后需要配置网卡转发,否则无法使用点对网\n";
toml += "# 通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好\n";
toml += "no_nat = true\n";
}
toml += "\n# 虚拟网卡模式:no(无网卡)、tun(三层网卡)、tap(二层网卡)\n";
toml += `device_mode = "${formData.device_mode || "tun"}"\n`;
const portMappings = formData.port_mapping.filter((s) => s.trim());
if (portMappings.length > 0) {
toml += "\n# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址\n";
toml += "# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址\n";
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 表示将本地tcp的81端口的数据转发到10.0.0.2:80\n";
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80\n";
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80\n";
toml += `port_mapping = [${portMappings.map((s) => `"${s}"`).join(", ")}]\n`;
}
if (formData.allow_mapping) {
toml += '\n# 是否允许作为端口映射出口,开启后其他设备才可使用本设备的ip为"目标虚拟IP"\n';
toml += "# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络\n";
toml += "allow_mapping = true\n";
}
if (formData.mtu) {
toml += "\n# MTU 设置\n";
toml += `mtu = ${formData.mtu}\n`;
}
toml += "\n# --- 设备配置 ---\n";
if (formData.device_name) {
toml += "\n# 设备名称 (可选,默认读取本机 hostname)\n";
toml += `device_name = "${formData.device_name}"\n`;
}
if (formData.device_id) {
toml += "\n# 设备 ID (可选,不填自动生成,不同设备ID不能相同)\n";
toml += `device_id = "${formData.device_id}"\n`;
}
if (formData.tun_name) {
toml += "\n# 虚拟网卡名称\n";
toml += `tun_name = "${formData.tun_name}"\n`;
}
if (formData.outbound_interface) {
toml += "\n# 绑定对外通信 Socket 的出口网卡名称(用于服务端通信、P2P 打洞及转发流量)\n";
toml += `outbound_interface = "${formData.outbound_interface}"\n`;
}
toml += "\n# --- 安全配置 ---\n";
if (formData.password) {
toml += "\n# 组网加密密码 (可选)\n";
toml += `password = "${formData.password}"\n`;
}
if (formData.cert_mode && formData.cert_mode !== "skip") {
toml += "\n# 证书校验方式:\n";
toml += "# skip 跳过验证(默认)\n";
toml += "# standard 使用系统证书验证\n";
toml += "# finger 使用证书指纹验证,服务端启动时日志会输出指纹\n";
if (formData.cert_mode === "finger" && formData.fingerprint) {
toml += `cert_mode = "finger:${formData.fingerprint}"\n`;
} else {
toml += `cert_mode = "${formData.cert_mode}"\n`;
}
}
const udpStuns = formData.udp_stun.filter((s) => s.trim());
if (udpStuns.length > 0) {
toml += "\n# 自定义UDP STUN地址,不设置则用默认stun\n";
toml += `udp_stun = [${udpStuns.map((s) => `"${s}"`).join(", ")}]\n`;
}
const tcpStuns = formData.tcp_stun.filter((s) => s.trim());
if (tcpStuns.length > 0) {
toml += "\n# 自定义TCP STUN地址,不设置则用默认stun\n";
toml += `tcp_stun = [${tcpStuns.map((s) => `"${s}"`).join(", ")}]\n`;
}
return toml;
};
// 新建配置的 TOML 模板(从旧代码逐字迁移)
export const NEW_CONFIG_TEMPLATE = `# config_name = "配置名称"
# --- 网络配置 ---
# 网络编号相同网络编号的会组在同一个虚拟网 (必填)
network_code = "your_network_code"
# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)
# dynamic 协议使用dns txt解析记录值
server = ["quic://1.2.3.4:29872"]
# ===简单使用以下参数可以不动===
# 自定义虚拟 IP (可选)
# ip = "10.10.0.2"
# 是否启用quic优化传输 (默认 false,设置为true时开启)
# 开启后传输过程几乎不会丢包但是延迟会有波动
# rtx = false
# 是否启用 FEC 前向纠错损失一定带宽来提升网络稳定性(默认 false,设置为true时开启)
# 开启后可以减少丢包率损失带宽但是延迟比较稳定带宽充足时可以使用此功能
# fec = false
# 是否关闭 P2P 打洞 (默认 false,设置为true时关闭)
# no_punch = false
# 是否启用 LZ4 压缩 (默认 false,设置为true时开启)
# compress = false
# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP)用于点对网将指定网段的流量发送到目标节点
# input = ["192.168.0.0/24,10.26.0.2", "192.168.1.0/24,10.26.0.3"]
# 出栈允许网段用于点对网允许指定网段的转发
# output = ["0.0.0.0/0"]
# 是否关闭内置子网NAT关闭(设为true)后需要配置网卡转发否则无法使用点对网通常关闭内置子网NAT使用系统的网卡转发点对网性能会更好
# no_nat = false
# 虚拟网卡模式no无网卡tun三层网卡默认tap二层网卡
# Windows tap 模式需要预先安装 tap-windows (tap0901) 驱动
device_mode = "tun"
# 端口映射格式为协议://本地监听地址-目标虚拟IP-目标映射地址
# 端口映射用于在本地监听指定端口并将收到的网络流量经由指定虚拟节点转发到目标地址从而实现跨网络或内网服务访问
# 例如 port_mapping = ["tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"]
# tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 则表示将本地tcp的81端口的数据转发到10.0.0.2:80
# tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80
# tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80
# port_mapping = []
# 是否允许作为端口映射出口开启(设置为true)后其他设备才可使用本设备的ip为"目标虚拟IP"
# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络
# allow_mapping = false
# MTU 设置
# mtu = 1400
# --- 设备配置 ---
# 设备名称 (可选默认读取本机 hostname)
# device_name = "my-device"
# 设备 ID (可选不填自动生成不同设备ID不能相同)
# device_id = "device-id-xxxx"
# 虚拟网卡名称
# tun_name = "vnt-tun"
# 绑定对外通信 Socket 的出口网卡名称例如 EthernetWi-Fieth0
# outbound_interface = "Ethernet"
# --- 安全配置 ---
# 加密密码 (可选)
# password = "123456"
# 证书校验方式
# skip 跳过验证默认
# standard 使用系统证书验证
# finger 使用证书指纹验证服务端启动时日志会输出指纹
# 例如 finger:3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1
# cert_mode = "skip"
# --- 其他配置 ---
# 自定义stun地址分别用于udp打洞和tcp打洞需要单独配置不设置则用默认stun
# udp_stun = ["stun.chat.bilibili.com"]
# tcp_stun = ["stun.nextcloud.com:443"]`;
+231
View File
@@ -0,0 +1,231 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useAppStore } from "../stores/app";
import { getRuntime, getVersion } from "../api";
import { isDesktop } from "../auth";
import vntIcon from "../assets/vnt-icon.png";
const PROJECT_URL = "https://github.com/vnt-dev/vnt";
const RELEASES_URL = `${PROJECT_URL}/releases`;
const RELEASES_API = "https://api.github.com/repos/vnt-dev/vnt/releases?per_page=20";
const app = useAppStore();
const runtime = ref(isDesktop ? "desktop" : "");
const checking = ref(false);
const installing = ref(false);
const updateInfo = ref(null);
const resultKind = ref("");
const message = ref("");
const downloaded = ref(0);
const contentLength = ref(0);
const currentVersion = computed(() => app.version || "2.0.2");
const progress = computed(() => {
if (!contentLength.value) return 0;
return Math.min(100, Math.round((downloaded.value / contentLength.value) * 100));
});
const versionParts = (value) => {
const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[+-].*)?$/);
return match ? match.slice(1).map(Number) : null;
};
const compareVersions = (left, right) => {
const a = versionParts(left);
const b = versionParts(right);
if (!a || !b) return 0;
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
}
return 0;
};
const openUrl = async (url) => {
if (globalThis.__VNT_WEB_ACCESS__?.openUrl) {
await globalThis.__VNT_WEB_ACCESS__.openUrl(url);
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
};
const checkGithubRelease = async () => {
const response = await fetch(RELEASES_API, {
headers: { Accept: "application/vnd.github+json" },
cache: "no-store",
});
if (!response.ok) throw new Error(`GitHub 返回 ${response.status}`);
const releases = (await response.json()).filter(
(release) => !release.draft && !release.prerelease && versionParts(release.tag_name),
);
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
const latest = releases[0];
if (!latest) throw new Error("没有找到可用的发布版本");
return {
version: latest.tag_name.replace(/^v/, ""),
body: latest.body || "",
url: latest.html_url || RELEASES_URL,
};
};
const checkUpdate = async () => {
checking.value = true;
resultKind.value = "";
message.value = "";
updateInfo.value = null;
try {
if (isDesktop) {
let update;
try {
update = await globalThis.__VNT_UPDATER__?.check();
} catch {
const latest = await checkGithubRelease();
if (compareVersions(latest.version, currentVersion.value) <= 0) {
resultKind.value = "latest";
message.value = "当前已是最新版本";
return;
}
updateInfo.value = { ...latest, manualOnly: true };
resultKind.value = "update";
message.value = `发现新版本 v${latest.version},该版本暂未提供自动更新包。`;
return;
}
if (!update) {
resultKind.value = "latest";
message.value = "当前已是最新版本";
return;
}
updateInfo.value = { ...update, url: RELEASES_URL };
resultKind.value = "update";
message.value = `发现新版本 v${update.version},可以直接下载并更新。`;
return;
}
runtime.value ||= await getRuntime();
const latest = await checkGithubRelease();
if (compareVersions(latest.version, currentVersion.value) <= 0) {
resultKind.value = "latest";
message.value = "当前已是最新版本";
return;
}
updateInfo.value = latest;
resultKind.value = "update";
message.value = runtime.value === "desktop_web"
? `发现新版本 v${latest.version},请回到 VNT Desktop 的“关于”页面完成更新。`
: `发现新版本 v${latest.version},请下载新版本并替换当前 vnt2_web 程序。`;
} catch (error) {
resultKind.value = "error";
message.value = `检查更新失败:${error?.message || error}`;
} finally {
checking.value = false;
}
};
const downloadAndInstall = async () => {
installing.value = true;
downloaded.value = 0;
contentLength.value = 0;
message.value = "正在准备下载更新…";
try {
await globalThis.__VNT_UPDATER__.downloadAndInstall((event) => {
downloaded.value = event.downloaded;
contentLength.value = event.contentLength;
message.value = event.event === "Finished" ? "下载完成,正在安装…" : "正在下载更新…";
});
} catch (error) {
resultKind.value = "error";
message.value = `更新失败:${error?.message || error}`;
installing.value = false;
}
};
onMounted(async () => {
if (!app.version) {
try {
app.version = await getVersion();
} catch {
//
}
}
if (!isDesktop) {
try {
runtime.value = await getRuntime();
} catch {
runtime.value = "standalone_web";
}
}
});
</script>
<template>
<div class="mx-auto max-w-3xl space-y-5">
<div>
<h1 class="page-title">关于</h1>
<p class="page-subtitle">VNT 客户端信息与软件更新</p>
</div>
<section class="card flex items-center gap-4">
<img :src="vntIcon" alt="VNT" class="h-16 w-16 shrink-0 rounded-2xl" />
<div class="min-w-0">
<h2 class="text-lg font-bold text-slate-900 dark:text-white">VNT</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">简单高效的异地组网与内网穿透工具</p>
<p class="mt-2 font-mono text-xs text-slate-400">当前版本 v{{ currentVersion }}</p>
</div>
</section>
<section class="card">
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">开源项目</h2>
<p class="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">项目代码使用说明和问题反馈均托管在 GitHub</p>
<button class="btn-ghost mt-4" type="button" @click="openUrl(PROJECT_URL)">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M14 5h5v5m0-5-9 9M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" />
</svg>
github.com/vnt-dev/vnt
</button>
</section>
<section class="card">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">软件更新</h2>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
{{ isDesktop ? "检查并安装 VNT Desktop 的最新版本。" : "检查 GitHub 上发布的最新版本。" }}
</p>
</div>
<button class="btn-primary" type="button" :disabled="checking || installing" @click="checkUpdate">
{{ checking ? "正在检查…" : "检查更新" }}
</button>
</div>
<div
v-if="message"
class="mt-5 rounded-lg border px-4 py-3 text-sm"
:class="resultKind === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
: resultKind === 'update'
? 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-900 dark:bg-indigo-950/40 dark:text-indigo-300'
: 'border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300'"
>
{{ message }}
</div>
<div v-if="installing && contentLength" class="mt-4">
<div class="mb-1.5 flex justify-between text-xs text-slate-400">
<span>下载进度</span>
<span>{{ progress }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div class="h-full rounded-full bg-indigo-600 transition-[width] dark:bg-indigo-500" :style="{ width: `${progress}%` }"></div>
</div>
</div>
<div v-if="resultKind === 'update'" class="mt-4 flex flex-wrap gap-2">
<button v-if="isDesktop && !updateInfo?.manualOnly" class="btn-primary" type="button" :disabled="installing" @click="downloadAndInstall">
{{ installing ? "正在更新…" : "下载并更新" }}
</button>
<button v-else-if="updateInfo?.manualOnly || runtime === 'standalone_web'" class="btn-ghost" type="button" @click="openUrl(updateInfo?.url || RELEASES_URL)">查看发布版本</button>
</div>
<p v-if="isDesktop" class="mt-4 text-xs leading-5 text-slate-400">安装更新时桌面客户端可能自动退出完成后将重新启动</p>
</section>
</div>
</template>
+630
View File
@@ -0,0 +1,630 @@
<script setup>
import { ref, watch, nextTick } from "vue";
import AppModal from "../components/AppModal.vue";
import AppSelect from "../components/AppSelect.vue";
import { useUiStore } from "../stores/ui";
import { getConfig, saveConfig } from "../api";
import { emptyFormData, parseTomlToForm, formToToml, NEW_CONFIG_TEMPLATE } from "../utils/toml";
const props = defineProps({
show: { type: Boolean, default: false },
// null
fileName: { type: String, default: null },
});
const emit = defineEmits(["close", "saved"]);
const ui = useUiStore();
const editorContent = ref("");
const editorFileName = ref("");
const editorMode = ref("new"); // 'new' 'edit'
const editMode = ref("form"); // 'form' 'toml'
const originalToml = ref(""); // TOML()
const hasTomlChanges = ref(false);
const hasFormChanges = ref(false);
const isParsingToml = ref(false);
const formData = ref(emptyFormData());
const certificateModeOptions = [
{ value: "skip", label: "跳过验证(默认)" },
{ value: "standard", label: "系统证书验证" },
{ value: "finger", label: "证书指纹验证" },
];
const deviceModeOptions = [
{ value: "no", label: "无虚拟网卡" },
{ value: "tun", label: "TUN(三层)" },
{ value: "tap", label: "TAP(二层)" },
];
//
watch(
() => props.show,
async (val) => {
if (!val) return;
editorFileName.value = props.fileName || "";
editorMode.value = props.fileName ? "edit" : "new";
editMode.value = "form"; //
hasTomlChanges.value = false;
hasFormChanges.value = false;
if (props.fileName) {
try {
const data = await getConfig(props.fileName);
editorContent.value = data;
originalToml.value = data; // TOML
isParsingToml.value = true;
formData.value = parseTomlToForm(data);
nextTick(() => {
isParsingToml.value = false;
});
} catch (e) {
ui.toast.error("获取配置失败: " + e.message);
emit("close");
}
} else {
// ,
originalToml.value = "";
formData.value = emptyFormData();
editorContent.value = NEW_CONFIG_TEMPLATE;
}
},
);
//
const switchToFormMode = () => {
if (editMode.value === "toml") {
try {
const parsed = parseTomlToForm(editorContent.value);
editMode.value = "form";
isParsingToml.value = true;
formData.value = parsed;
nextTick(() => {
isParsingToml.value = false;
});
} catch (e) {
ui.toast.error("配置解析失败: " + e.message);
}
} else {
editMode.value = "form";
}
};
// TOML
const switchToTomlMode = () => {
if (editMode.value === "form") {
// ,TOML
if (hasFormChanges.value) {
editorContent.value = formToToml(formData.value);
hasFormChanges.value = false;
} else if (originalToml.value && !hasTomlChanges.value) {
// TOML,使TOML()
editorContent.value = originalToml.value;
} else {
editorContent.value = formToToml(formData.value);
}
}
editMode.value = "toml";
};
// TOML(TOML)
watch(editorContent, (newVal, oldVal) => {
if (editMode.value === "toml" && oldVal !== undefined) {
hasTomlChanges.value = true;
}
});
//
watch(
formData,
() => {
if (editMode.value === "form" && props.show && !isParsingToml.value) {
hasFormChanges.value = true;
}
},
{ deep: true },
);
const handleSave = async () => {
try {
// TOML
let configContent = editorContent.value;
if (editMode.value === "form") {
//
if (!formData.value.network_code.trim()) {
ui.toast.error("请填写网络编号");
return;
}
const servers = formData.value.server.filter((s) => s.trim());
if (servers.length === 0) {
ui.toast.error("请至少填写一个服务器地址");
return;
}
configContent = formToToml(formData.value);
}
await saveConfig(editorFileName.value || null, configContent);
ui.toast.success("配置已保存");
emit("close");
emit("saved");
} catch (e) {
ui.toast.error("保存失败: " + e.message);
}
};
//
const checkboxClass =
"h-5 w-5 rounded border-slate-300 bg-white text-indigo-600 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-700";
const toggleLabelClass =
"flex cursor-pointer items-center justify-between gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:bg-slate-800";
const removeBtnClass =
"shrink-0 rounded-lg bg-red-50 px-3 py-2 text-red-500 transition-colors hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:hover:bg-red-900/40";
const addBtnClass =
"flex w-full items-center justify-center gap-1 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-200 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700";
const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-900 dark:text-white";
</script>
<template>
<AppModal
:show="show"
:mask-closable="false"
panel-class="w-full max-w-6xl h-[85vh]"
@close="emit('close')"
>
<template #header>
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
{{ editorMode === "new" ? "新建配置" : "编辑配置" }}
</h3>
<div class="flex items-center gap-4">
<!-- 模式切换按钮 -->
<div class="flex rounded-lg border border-slate-300 bg-slate-100 p-1 dark:border-slate-600 dark:bg-slate-800">
<button
@click="switchToFormMode"
:class="editMode === 'form' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
>
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
表单模式
</button>
<button
@click="switchToTomlMode"
:class="editMode === 'toml' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
>
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
TOML模式
</button>
</div>
<div class="text-sm text-slate-500 font-mono hidden md:block" v-if="editorFileName">
{{ editorFileName }}
</div>
</div>
</template>
<template #body>
<!-- 表单模式 -->
<div v-show="editMode === 'form'" class="h-full overflow-y-auto scrollbar-hide p-6">
<div class="max-w-4xl mx-auto space-y-6">
<!-- 基础配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
基础配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">配置名称</label>
<input v-model="formData.config_name" type="text" placeholder="例如: 我的VPN配置" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
网络编号 <span class="text-red-500">*</span>
</label>
<input v-model="formData.network_code" type="text" placeholder="例如: my_network" required class="input" />
</div>
</div>
<div class="mt-4">
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
服务器地址 <span class="text-red-500">*</span>
<span class="text-xs text-slate-500 ml-2">支持 quic:// tcp:// wss:// dynamic://</span>
</label>
<div class="space-y-2">
<div v-for="(server, idx) in formData.server" :key="idx" class="flex gap-2">
<input v-model="formData.server[idx]" type="text" placeholder="例如: quic://1.2.3.4:29872" class="input flex-1" />
<button @click="formData.server.splice(idx, 1)" v-if="formData.server.length > 1" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<button
@click="formData.server.push('')"
:class="addBtnClass"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
添加服务器
</button>
</div>
</div>
</div>
<!-- 网络设置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
网络设置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
自定义虚拟IP
<span class="text-xs text-slate-500 ml-1">(可选)</span>
</label>
<input v-model="formData.ip" type="text" placeholder="例如: 10.26.0.2" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">MTU</label>
<input v-model.number="formData.mtu" type="number" placeholder="1380" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">隧道端口</label>
<input v-model.number="formData.tunnel_port" type="number" placeholder="0 (自动分配)" class="input" />
</div>
</div>
</div>
<!-- 传输优化 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
传输优化
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">QUIC传输优化</div>
<div class="text-xs text-slate-400 mt-0.5">重传丢包</div>
</div>
<input v-model="formData.rtx" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">FEC前向纠错</div>
<div class="text-xs text-slate-400 mt-0.5">损失部分带宽提升稳定性</div>
</div>
<input v-model="formData.fec" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">LZ4压缩</div>
<div class="text-xs text-slate-400 mt-0.5">减少传输数据量</div>
</div>
<input v-model="formData.compress" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭P2P打洞</div>
<div class="text-xs text-slate-400 mt-0.5">仅通过服务器中转</div>
</div>
<input v-model="formData.no_punch" type="checkbox" :class="checkboxClass" />
</label>
</div>
</div>
<!-- 安全配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
安全配置
</h4>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300"
>组网加密密码(同一组网密码需要相同)</label
>
<input v-model="formData.password" type="password" placeholder="留空则不加密" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">服务端证书校验模式</label>
<AppSelect v-model="formData.cert_mode" :options="certificateModeOptions" aria-label="服务端证书校验模式" />
</div>
</div>
<div v-if="formData.cert_mode === 'finger'">
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
证书指纹
<span class="text-xs text-slate-500 ml-1">(服务端启动时日志会输出指纹)</span>
</label>
<input
v-model="formData.fingerprint"
type="text"
placeholder="例如: 3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1"
class="input font-mono text-sm"
/>
</div>
</div>
</div>
<!-- NAT与路由 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 01-.806-.98L15 9m0 0V7m0 2v6"
/>
</svg>
NAT与路由 (点对网)
</h4>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
入栈网段
<span class="text-xs text-slate-500 ml-1">格式: CIDR,目标IP</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.input" :key="idx" class="flex gap-2">
<input v-model="formData.input[idx]" type="text" placeholder="例如: 192.168.0.0/24,10.26.0.2" class="input flex-1" />
<button @click="formData.input.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.input.push('')"
:class="addBtnClass"
>
+ 添加入栈网段
</button>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
出栈网段
<span class="text-xs text-slate-500 ml-1">格式: CIDR (允许转发的网段)</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.output" :key="idx" class="flex gap-2">
<input v-model="formData.output[idx]" type="text" placeholder="例如: 0.0.0.0/0" class="input flex-1" />
<button @click="formData.output.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.output.push('')"
:class="addBtnClass"
>
+ 添加出栈网段
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭内置NAT</div>
<div class="text-xs text-slate-400 mt-0.5">使用系统网卡转发</div>
</div>
<input v-model="formData.no_nat" type="checkbox" :class="checkboxClass" />
</label>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-800/50">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">虚拟网卡模式</div>
<div class="text-xs text-slate-400 mt-0.5">TAP 为二层网卡Windows 需要 tap-windows 驱动</div>
</div>
<AppSelect v-model="formData.device_mode" :options="deviceModeOptions" class="mt-2" aria-label="虚拟网卡模式" />
</div>
</div>
</div>
</div>
<!-- 端口映射 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
端口映射
</h4>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
映射规则
<span class="text-xs text-slate-500 ml-1">格式: 协议://-IP-</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.port_mapping" :key="idx" class="flex gap-2">
<input
v-model="formData.port_mapping[idx]"
type="text"
placeholder="例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"
class="input flex-1 font-mono"
/>
<button @click="formData.port_mapping.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.port_mapping.push('')"
:class="addBtnClass"
>
+ 添加映射规则
</button>
</div>
</div>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">允许作为映射出口</div>
<div class="text-xs text-slate-400 mt-0.5">允许其他设备使用本机作跳板来进行端口映射</div>
</div>
<input v-model="formData.allow_mapping" type="checkbox" :class="checkboxClass" />
</label>
</div>
</div>
<!-- 设备配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
设备配置
</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备名称</label>
<input v-model="formData.device_name" type="text" placeholder="默认为主机名" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备ID</label>
<input v-model="formData.device_id" type="text" placeholder="自动生成" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">虚拟网卡名</label>
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">绑定出口网卡</label>
<input
v-model="formData.outbound_interface"
type="text"
placeholder="例如 Ethernet、Wi-Fi、eth0"
class="input"
/>
<p class="mt-1.5 text-xs leading-5 text-slate-400">服务端通信P2P 打洞及转发流量将使用此网卡</p>
</div>
</div>
</div>
<!-- STUN配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
STUN配置 (高级)
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">UDP STUN服务器</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.udp_stun" :key="idx" class="flex gap-2">
<input v-model="formData.udp_stun[idx]" type="text" placeholder="例如: stun.l.google.com:19302" class="input flex-1" />
<button @click="formData.udp_stun.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.udp_stun.push('')"
:class="addBtnClass"
>
+ 添加UDP STUN
</button>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">TCP STUN服务器</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.tcp_stun" :key="idx" class="flex gap-2">
<input v-model="formData.tcp_stun[idx]" type="text" placeholder="例如: stun.nextcloud.com:443" class="input flex-1" />
<button @click="formData.tcp_stun.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.tcp_stun.push('')"
:class="addBtnClass"
>
+ 添加TCP STUN
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- TOML模式 -->
<div v-show="editMode === 'toml'" class="h-full">
<textarea
v-model="editorContent"
class="h-full w-full resize-none bg-slate-50 p-4 font-mono text-sm text-slate-800 focus:outline-none dark:bg-slate-950 dark:text-slate-200"
spellcheck="false"
placeholder="# 在此处输入 TOML 配置..."
></textarea>
</div>
</template>
<template #footer>
<div class="flex-1 text-left text-xs text-slate-500">
<span v-if="editMode === 'form'">填写完成后保存即可生成配置文件</span>
<span v-else>* 请使用标准 TOML 格式</span>
</div>
<button class="btn-ghost" @click="emit('close')">取消</button>
<button class="btn-primary" @click="handleSave">保存配置</button>
</template>
</AppModal>
</template>
+227
View File
@@ -0,0 +1,227 @@
<script setup>
import { ref, onMounted } from "vue";
import QRCode from "qrcode";
import { useAppStore } from "../stores/app";
import { useUiStore } from "../stores/ui";
import { deleteConfig, getConfig } from "../api";
import EmptyState from "../components/EmptyState.vue";
import AppModal from "../components/AppModal.vue";
import ConfigEditor from "./ConfigEditor.vue";
import { parseTomlToForm } from "../utils/toml";
import { buildNetworkQrPayload } from "../utils/networkQr";
const app = useAppStore();
const ui = useUiStore();
const showEditor = ref(false);
const editorFileName = ref(null);
const showQr = ref(false);
const qrLoading = ref(false);
const qrImage = ref("");
const qrConfig = ref(null);
const qrError = ref("");
// ( null)
const instStatus = (fileName) => {
const inst = app.instanceList.find((i) => i.file_name === fileName);
return inst ? inst.status : null;
};
const cardClass = (fileName) => {
const status = instStatus(fileName);
if (status === "running") return "ring-2 ring-green-500/60";
if (status === "starting") return "ring-2 ring-indigo-500/60";
return "";
};
const openEditor = (fileName) => {
editorFileName.value = fileName;
showEditor.value = true;
};
const onSaved = () => {
app.fetchConfigList();
};
const openQr = async (fileName) => {
showQr.value = true;
qrLoading.value = true;
qrImage.value = "";
qrConfig.value = null;
qrError.value = "";
try {
const form = parseTomlToForm(await getConfig(fileName));
const payload = buildNetworkQrPayload(form);
qrConfig.value = payload;
qrImage.value = await QRCode.toDataURL(JSON.stringify(payload), {
errorCorrectionLevel: "M",
width: 360,
margin: 2,
color: { dark: "#020617", light: "#ffffff" },
});
} catch (e) {
qrError.value = e.message || "二维码生成失败";
} finally {
qrLoading.value = false;
}
};
const handleDelete = async (fileName) => {
const ok = await ui.confirm({
title: "删除配置",
message: `确定要删除配置 ${fileName} 吗?`,
danger: true,
confirmText: "删除",
});
if (!ok) return;
try {
await deleteConfig(fileName);
ui.toast.success("配置已删除");
app.fetchConfigList();
} catch (e) {
ui.toast.error(e.message);
}
};
onMounted(() => app.fetchConfigList());
</script>
<template>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="page-title">配置</h1>
<p class="page-subtitle">管理组网配置文件</p>
</div>
<button class="btn-primary" @click="openEditor(null)">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
新建配置
</button>
</div>
<EmptyState v-if="app.configList.length === 0" text="暂无配置,点击右上角新建" />
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div
v-for="cfg in app.configList"
:key="cfg.file_name"
:class="cardClass(cfg.file_name)"
class="card group relative flex min-h-[140px] cursor-pointer flex-col justify-between overflow-hidden"
@click="openEditor(cfg.file_name)"
>
<button
class="absolute right-3 top-3 z-10 rounded-lg p-1.5 text-slate-400 transition hover:bg-indigo-50 hover:text-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-400"
type="button"
title="显示加入网络二维码"
aria-label="显示加入网络二维码"
@click.stop="openQr(cfg.file_name)"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm12 0h2m4 0v2m-6 2h2v3m4-3v3h-2" />
</svg>
</button>
<div
v-if="instStatus(cfg.file_name) === 'running'"
class="absolute right-12 top-3 rounded bg-green-500 px-2 py-1 text-xs text-white"
>
运行中
</div>
<div
v-else-if="instStatus(cfg.file_name) === 'starting'"
class="absolute right-12 top-3 rounded bg-indigo-500 px-2 py-1 text-xs text-white"
>
启动中
</div>
<div class="flex items-start pr-28">
<div class="mr-3 mt-1 rounded-lg bg-indigo-50 p-2 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div class="overflow-hidden">
<h3 class="truncate text-lg font-bold text-slate-900 dark:text-white" :title="cfg.config_name">
{{ cfg.config_name || "Unnamed" }}
</h3>
<p class="truncate font-mono text-xs text-slate-400" :title="cfg.file_name">
{{ cfg.file_name }}
</p>
</div>
</div>
<div class="config-card-actions mt-4 flex justify-end transition-opacity">
<button
class="mr-4 text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
@click.stop="openEditor(cfg.file_name)"
>
编辑
</button>
<button class="text-sm text-red-500 hover:text-red-400" @click.stop="handleDelete(cfg.file_name)">
删除
</button>
</div>
</div>
</div>
<ConfigEditor
:show="showEditor"
:file-name="editorFileName"
@close="showEditor = false"
@saved="onSaved"
/>
<AppModal :show="showQr" panel-class="w-full max-w-md" @close="showQr = false">
<template #header>
<div>
<h2 class="text-lg font-bold text-slate-900 dark:text-white">扫码加入网络</h2>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">使用 VNT 安卓客户端扫描</p>
</div>
<button class="btn-ghost btn-sm" type="button" @click="showQr = false">关闭</button>
</template>
<template #body>
<div class="p-6">
<div v-if="qrLoading" class="py-20 text-center text-sm muted">正在生成二维码</div>
<div v-else-if="qrError" class="rounded-lg bg-red-50 p-4 text-sm text-red-600 dark:bg-red-500/10 dark:text-red-400">
{{ qrError }}
</div>
<div v-else-if="qrConfig" class="space-y-5">
<div class="mx-auto w-fit rounded-xl border border-slate-200 bg-white p-3 shadow-sm">
<img :src="qrImage" class="h-auto w-full max-w-[320px]" alt="VNT 加入网络二维码" />
</div>
<dl class="space-y-2 rounded-lg bg-slate-50 p-4 text-sm dark:bg-slate-800/60">
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">组网编号</dt><dd class="min-w-0 break-all font-mono text-slate-900 dark:text-white">{{ qrConfig.network_code }}</dd></div>
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">服务器</dt><dd class="min-w-0 break-all font-mono text-slate-900 dark:text-white">{{ qrConfig.server.join(", ") }}</dd></div>
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">MTU</dt><dd class="font-mono text-slate-900 dark:text-white">{{ qrConfig.mtu }}</dd></div>
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">加密密码</dt><dd class="text-slate-900 dark:text-white">{{ qrConfig.password ? "已包含" : "未设置" }}</dd></div>
</dl>
<p class="text-xs leading-5 text-amber-600 dark:text-amber-400">二维码包含组网凭据请仅分享给可信设备</p>
</div>
</div>
</template>
</AppModal>
</div>
</template>
<style scoped>
/* 触屏和窄屏设备没有可靠的 hover,操作按钮必须直接可见。 */
.config-card-actions {
opacity: 1;
}
/* 只有宽屏且确实支持精细悬停的设备才使用移入显示。 */
@media (min-width: 640px) and (hover: hover) and (pointer: fine) {
.config-card-actions {
opacity: 0;
}
.group:hover .config-card-actions,
.group:focus-within .config-card-actions {
opacity: 1;
}
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<script setup>
import { computed } from "vue";
import { useAppStore } from "../stores/app";
import StartPanel from "../components/StartPanel.vue";
import InstanceCard from "../components/InstanceCard.vue";
import EmptyState from "../components/EmptyState.vue";
const app = useAppStore();
// 线: running info 线
const onlineDevices = computed(() =>
Object.values(app.instances).reduce(
(sum, info) => sum + (info?.online_client_num || 0),
0,
),
);
// : running / running
const serverConnected = computed(() => {
const running = app.instanceList.filter((i) => i.status === "running");
const connected = running.filter((i) => {
const info = app.instances[i.file_name];
return info?.server_info?.some((s) => s.connected);
});
return { connected: connected.length, total: running.length };
});
const statusSummary = computed(() => {
if (app.runningCount > 0) return `运行中 ${app.runningCount} 个实例`;
if (app.startingCount > 0) return "有实例正在启动...";
return "全部实例已停止";
});
</script>
<template>
<div class="space-y-6">
<!-- 欢迎/状态区 -->
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="page-title">总览</h1>
<p class="page-subtitle">{{ statusSummary }}</p>
</div>
<router-link to="/config" class="btn-ghost btn-sm">管理配置</router-link>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card">
<div class="text-xs font-medium muted">运行实例</div>
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
{{ app.runningCount
}}<span class="ml-1 text-sm font-normal muted">/ {{ app.instanceList.length }}</span>
</div>
</div>
<div class="card">
<div class="text-xs font-medium muted">配置总数</div>
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
{{ app.configList.length }}
</div>
</div>
<div class="card">
<div class="text-xs font-medium muted">在线设备</div>
<div
class="mt-2 text-2xl font-bold tabular-nums"
:class="onlineDevices > 0 ? 'text-green-600 dark:text-green-400' : 'text-slate-900 dark:text-white'"
>
{{ onlineDevices }}
</div>
</div>
<div class="card">
<div class="text-xs font-medium muted">服务器连接</div>
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
<template v-if="serverConnected.total > 0">
<span :class="serverConnected.connected > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500'">{{
serverConnected.connected
}}</span>
<span class="text-sm font-normal muted">/ {{ serverConnected.total }} 已连接</span>
</template>
<span v-else class="muted">-</span>
</div>
</div>
</div>
<!-- 启动组网 -->
<StartPanel />
<!-- 实例列表 -->
<div>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-base font-bold text-slate-900 dark:text-white">组网实例</h2>
<span v-if="app.instanceList.length > 0" class="text-xs muted">点击卡片查看详情</span>
</div>
<EmptyState
v-if="app.instanceList.length === 0"
:text="app.configList.length === 0 ? '暂无配置,请先新建配置' : '暂无运行中的组网,请在上方选择配置启动'"
/>
<TransitionGroup v-else name="card-list" tag="div" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InstanceCard v-for="inst in app.instanceList" :key="inst.file_name" :inst="inst" selectable />
</TransitionGroup>
</div>
<!-- 选中实例详情 -->
<template v-if="app.selectedInstance && app.selectedInfo">
<div class="card">
<h2 class="mb-4 text-lg font-bold text-slate-900 dark:text-white">
网络详情
<span class="ml-2 text-sm font-medium text-indigo-600 dark:text-indigo-400">{{
app.selectedConfigName
}}</span>
</h2>
<div class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">虚拟 IP / 掩码</span>
<span class="font-mono tabular-nums text-slate-900 dark:text-white"
>{{ app.selectedInfo.ip || "-" }} / {{ app.selectedInfo.prefix_len || "-" }}</span
>
</div>
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">网关</span>
<span class="font-mono text-slate-900 dark:text-white">{{ app.selectedInfo.gateway || "-" }}</span>
</div>
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">网络编号</span>
<span class="font-mono text-slate-900 dark:text-white">{{ app.selectedInfo.network_code || "-" }}</span>
</div>
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">MTU</span>
<span class="font-mono tabular-nums text-slate-900 dark:text-white">{{ app.selectedInfo.mtu || "" }}</span>
</div>
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">NAT 类型</span>
<span class="font-mono text-indigo-600 dark:text-indigo-400">{{
app.selectedInfo.nat_type || "Unknown"
}}</span>
</div>
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">Public IPv6</span>
<span
class="max-w-[200px] truncate font-mono text-slate-900 dark:text-white"
:title="app.selectedInfo.public_ipv6"
>{{ app.selectedInfo.public_ipv6 || "-" }}</span
>
</div>
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">设备名称</span>
<span class="text-slate-900 dark:text-white">{{ app.selectedInfo.name || "-" }}</span>
</div>
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
<span class="muted">设备 ID</span>
<span
class="max-w-[200px] truncate font-mono text-xs text-slate-500 dark:text-slate-400"
:title="app.selectedInfo.device_id"
>{{ app.selectedInfo.device_id || "-" }}</span
>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<div
v-for="feat in [
{ key: 'encrypt', label: '加密' },
{ key: 'compress', label: '压缩' },
{ key: 'fec', label: 'FEC纠错' },
{ key: 'rtx', label: 'QUIC传输' },
]"
:key="feat.key"
class="flex items-center gap-2 rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-800/50"
>
<span
class="h-2 w-2 rounded-full"
:class="app.selectedInfo[feat.key] ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'"
></span>
<span class="text-sm text-slate-600 dark:text-slate-300">{{ feat.label }}</span>
</div>
</div>
<div class="mt-4">
<span class="mb-2 block text-sm muted">Public IPv4s</span>
<div class="flex flex-wrap gap-2">
<span
v-for="pip in app.selectedInfo.public_ipv4s"
:key="pip"
class="rounded border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs tabular-nums text-green-700 dark:border-slate-600 dark:bg-slate-800 dark:text-green-300"
>{{ pip }}</span
>
<span
v-if="!app.selectedInfo.public_ipv4s || app.selectedInfo.public_ipv4s.length === 0"
class="text-xs text-slate-400"
></span
>
</div>
</div>
</div>
<div class="card p-0 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<h2 class="text-lg font-bold text-slate-900 dark:text-white">服务器连接列表</h2>
</div>
<div class="custom-scrollbar max-h-[400px] overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>地址</th>
<th>状态</th>
<th>延迟</th>
<th>版本</th>
</tr>
</thead>
<tbody>
<tr v-for="(server, idx) in app.selectedInfo.server_info" :key="idx">
<td class="font-mono">{{ server.server }}</td>
<td>
<span :class="server.connected ? 'badge-green' : 'badge-red'">
{{ server.connected ? "已连接" : "未连接" }}
</span>
</td>
<td class="tabular-nums">{{ server.server_rtt ? server.server_rtt + " ms" : "-" }}</td>
<td>{{ server.server_version || "-" }}</td>
</tr>
<tr v-if="!app.selectedInfo.server_info || app.selectedInfo.server_info.length === 0">
<td colspan="4" class="text-center text-slate-400">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
+336
View File
@@ -0,0 +1,336 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted, watch, inject } from "vue";
import { useAppStore } from "../stores/app";
import { getPeers } from "../api";
import { formatTime, formatBytes, formatSpeed } from "../utils/format";
import StatusDot from "../components/StatusDot.vue";
import EmptyState from "../components/EmptyState.vue";
import SpeedChart from "../components/SpeedChart.vue";
const app = useAppStore();
const tooltipRef = inject("peerTooltip");
const showPeerTooltip = (e, peer) => tooltipRef.value?.showPeerTooltip(e, peer);
const hidePeerTooltip = () => tooltipRef.value?.hidePeerTooltip();
const peers = ref([]);
let timer = null;
// ,
let lastTrafficMap = {};
let lastFetchTime = 0;
//
const expandedPeers = reactive({});
const speedHistoryMap = reactive({});
const HISTORY_SIZE = 60;
const toggleExpand = (ip) => {
expandedPeers[ip] = !expandedPeers[ip];
};
const currentStatus = () => {
const inst = app.instanceList.find((i) => i.file_name === app.selectedInstance);
return inst ? inst.status : null;
};
const resetPeerState = () => {
peers.value = [];
lastTrafficMap = {};
lastFetchTime = 0;
for (const key in speedHistoryMap) delete speedHistoryMap[key];
for (const key in expandedPeers) delete expandedPeers[key];
};
const fetchPeers = async () => {
if (!app.selectedInstance || currentStatus() !== "running") {
resetPeerState();
return;
}
try {
const list = (await getPeers(app.selectedInstance)) || [];
const now = Date.now();
const elapsed = lastFetchTime > 0 ? (now - lastFetchTime) / 1000 : 0;
const newTrafficMap = {};
for (const peer of list) {
if (peer.traffic) {
const key = peer.ip;
const prev = lastTrafficMap[key];
if (prev && elapsed > 0) {
const txDiff = Math.max(0, peer.traffic.tx_bytes - prev.tx_bytes);
const rxDiff = Math.max(0, peer.traffic.rx_bytes - prev.rx_bytes);
peer.traffic.tx_speed = Math.round(txDiff / elapsed);
peer.traffic.rx_speed = Math.round(rxDiff / elapsed);
} else {
peer.traffic.tx_speed = 0;
peer.traffic.rx_speed = 0;
}
newTrafficMap[key] = { tx_bytes: peer.traffic.tx_bytes, rx_bytes: peer.traffic.rx_bytes };
//
if (!speedHistoryMap[key]) speedHistoryMap[key] = { tx: [], rx: [] };
speedHistoryMap[key].tx.push(peer.traffic.tx_speed);
speedHistoryMap[key].rx.push(peer.traffic.rx_speed);
if (speedHistoryMap[key].tx.length > HISTORY_SIZE) {
speedHistoryMap[key].tx.shift();
speedHistoryMap[key].rx.shift();
}
}
}
lastTrafficMap = newTrafficMap;
lastFetchTime = now;
peers.value = list;
} catch (e) {
console.error(e);
}
};
onMounted(() => {
fetchPeers();
timer = setInterval(() => {
if (app.isPageVisible) fetchPeers();
}, 3000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
//
watch(
() => app.selectedInstance,
() => {
resetPeerState();
fetchPeers();
},
);
// , running
watch(currentStatus, (newStatus) => {
if (newStatus === "running") {
fetchPeers();
}
});
const getRouteModeClass = (route) => {
// :metric === 1
const isDirect = route.metric === 1;
if (isDirect) {
return "font-medium text-green-600 dark:text-green-400";
} else {
return "text-blue-600 dark:text-blue-400";
}
};
const getRouteModeText = (route) => {
const isDirect = route.metric === 1;
const isTcp = route.protocol.includes("Tcp");
if (isDirect) {
return isTcp ? "打洞TCP直连" : "打洞UDP直连";
} else {
return isTcp ? "客户端TCP中继" : "客户端UDP中继";
}
};
const keyEqualText = (keyEqual) =>
keyEqual === 3
? "己方加密对方未加密"
: keyEqual === 4
? "己方未加密对方加密"
: keyEqual === 5
? "密钥不一致"
: "未知错误";
const switcherClass = (fileName) =>
app.selectedInstance === fileName
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700";
</script>
<template>
<div class="space-y-4">
<div>
<h1 class="page-title">设备列表</h1>
<p class="page-subtitle">查看各实例的设备连接与流量状况</p>
</div>
<!-- 实例切换器 -->
<div v-if="app.instanceList.length > 0" class="scrollbar-hide flex items-center gap-2 overflow-x-auto">
<button
v-for="inst in app.instanceList"
:key="inst.file_name"
@click="app.selectedInstance = inst.file_name"
:class="switcherClass(inst.file_name)"
class="flex shrink-0 items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
>
<StatusDot :status="inst.status" size="w-2 h-2" class="mr-2" />
{{ inst.config_name || inst.file_name }}
</button>
</div>
<EmptyState v-if="!app.selectedInstance" text="暂无运行中的组网实例" />
<div v-else class="card overflow-hidden p-0">
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<h2 class="text-base font-bold text-slate-900 dark:text-white">设备列表</h2>
<div class="flex gap-4 text-sm muted">
<span>
Online:
<span class="font-medium tabular-nums text-slate-900 dark:text-white">{{
peers.filter((p) => p.online).length
}}</span>
</span>
<span>
Total:
<span class="font-medium tabular-nums text-slate-900 dark:text-white">{{ peers.length }}</span>
</span>
</div>
</div>
<div class="custom-scrollbar max-h-[600px] overflow-x-auto">
<table class="table peer-table">
<thead>
<tr>
<th class="w-8 px-2"></th>
<th>IP地址</th>
<th>名称</th>
<th class="hidden md:table-cell">版本</th>
<th>状态</th>
<th>模式</th>
<th>延迟</th>
<th>丢包率</th>
<th>流量</th>
<th class="hidden md:table-cell">最后在线</th>
</tr>
</thead>
<tbody>
<template v-for="peer in peers" :key="peer.ip">
<tr class="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td class="cursor-pointer select-none px-2 text-center" @click="toggleExpand(peer.ip)">
<svg
class="inline-block h-4 w-4 text-slate-400 transition-transform duration-200"
:class="{ 'rotate-90': expandedPeers[peer.ip] }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</td>
<td class="font-mono tabular-nums text-indigo-600 dark:text-indigo-400">
<span
class="cursor-help border-b border-dotted border-indigo-400/50 pb-0.5"
@mouseenter="showPeerTooltip($event, peer)"
@mouseleave="hidePeerTooltip"
>
{{ peer.ip }}
</span>
</td>
<td>{{ peer.name || "-" }}</td>
<td class="hidden text-xs text-slate-400 md:table-cell">{{ peer.version || "-" }}</td>
<td>
<div class="flex items-center gap-2">
<span :class="peer.online ? 'badge-green' : 'badge-gray'">{{
peer.online ? "在线" : "离线"
}}</span>
<!-- 加密状态图标 -->
<div v-if="peer.online && peer.key_equal === 1" class="tooltip">
<svg class="h-4 w-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span class="tooltip-text">双方加密传输</span>
</div>
<div v-else-if="peer.online && peer.key_equal === 2" class="tooltip">
<svg class="h-4 w-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
/>
</svg>
<span class="tooltip-text">双方未加密</span>
</div>
<div v-else-if="peer.online && [3, 4, 5].includes(peer.key_equal)" class="tooltip cursor-help">
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="tooltip-text">{{ keyEqualText(peer.key_equal) }}</span>
</div>
</div>
</td>
<td>
<span v-if="peer.online && peer.route" :class="getRouteModeClass(peer.route)">{{
getRouteModeText(peer.route)
}}</span>
<span v-else-if="peer.online" class="text-yellow-600 dark:text-yellow-400">服务器中继</span>
<span v-else class="text-slate-400">-</span>
</td>
<td class="tabular-nums">{{ peer.route ? peer.route.rtt + " ms" : "-" }}</td>
<td>
<span
v-if="peer.packet_loss"
:class="
peer.packet_loss.loss_rate > 10
? 'text-red-600 dark:text-red-400'
: peer.packet_loss.loss_rate > 5
? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400'
"
:title="'Sent: ' + peer.packet_loss.sent + ', Received: ' + peer.packet_loss.received"
>{{ peer.packet_loss.loss_rate.toFixed(1) }}%</span
>
<span v-else class="text-slate-400">-</span>
</td>
<td class="text-xs">
<div v-if="peer.traffic" class="leading-relaxed">
<div class="text-green-600 tabular-nums dark:text-green-400">
{{ formatBytes(peer.traffic.tx_bytes) }} ({{ formatSpeed(peer.traffic.tx_speed) }})
</div>
<div class="text-blue-600 tabular-nums dark:text-blue-400">
{{ formatBytes(peer.traffic.rx_bytes) }} ({{ formatSpeed(peer.traffic.rx_speed) }})
</div>
</div>
<span v-else class="text-slate-400">-</span>
</td>
<td class="hidden font-mono text-xs text-slate-400 md:table-cell">
{{ formatTime(peer.last_connected_time) }}
</td>
</tr>
<tr v-if="expandedPeers[peer.ip]">
<td colspan="10" class="p-0">
<div class="border-t border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700/50 dark:bg-slate-950/60">
<SpeedChart :history="speedHistoryMap[peer.ip] || { tx: [], rx: [] }" :size="60" />
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
.peer-table :deep(thead th) {
padding: 0.5rem;
font-size: 0.6875rem;
}
.peer-table :deep(tbody td) {
padding: 0.5rem;
font-size: 0.75rem;
}
.peer-table :deep(th:first-child),
.peer-table :deep(td:first-child) {
padding-left: 0.25rem;
padding-right: 0;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script setup>
import { ref, onMounted, onUnmounted, watch } from "vue";
import { useAppStore } from "../stores/app";
import { getRoutes } from "../api";
import StatusDot from "../components/StatusDot.vue";
import EmptyState from "../components/EmptyState.vue";
const app = useAppStore();
const routes = ref([]);
let timer = null;
const currentStatus = () => {
const inst = app.instanceList.find((i) => i.file_name === app.selectedInstance);
return inst ? inst.status : null;
};
const fetchRoutes = async () => {
if (!app.selectedInstance || currentStatus() !== "running") {
routes.value = [];
return;
}
try {
routes.value = (await getRoutes(app.selectedInstance)) || [];
} catch (e) {
console.error(e);
}
};
onMounted(() => {
fetchRoutes();
timer = setInterval(() => {
if (app.isPageVisible) fetchRoutes();
}, 3000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
//
watch(
() => app.selectedInstance,
() => {
fetchRoutes();
},
);
// , running
watch(currentStatus, (newStatus) => {
if (newStatus === "running") {
fetchRoutes();
}
});
const switcherClass = (fileName) =>
app.selectedInstance === fileName
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700";
</script>
<template>
<div class="space-y-4">
<div>
<h1 class="page-title">路由</h1>
<p class="page-subtitle">查看各实例的路由表</p>
</div>
<!-- 实例切换器 -->
<div v-if="app.instanceList.length > 0" class="scrollbar-hide flex items-center gap-2 overflow-x-auto">
<button
v-for="inst in app.instanceList"
:key="inst.file_name"
@click="app.selectedInstance = inst.file_name"
:class="switcherClass(inst.file_name)"
class="flex shrink-0 items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
>
<StatusDot :status="inst.status" size="w-2 h-2" class="mr-2" />
{{ inst.config_name || inst.file_name }}
</button>
</div>
<EmptyState v-if="!app.selectedInstance" text="暂无运行中的组网实例" />
<div v-else class="card overflow-hidden p-0">
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<h2 class="text-base font-bold text-slate-900 dark:text-white">路由表</h2>
</div>
<div class="custom-scrollbar max-h-[600px] overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>目标节点IP</th>
<th>目标网络</th>
<th>跳数</th>
<th>延迟</th>
</tr>
</thead>
<tbody>
<template v-for="item in routes" :key="item.ip">
<tr
v-for="(route, rIdx) in item.routes"
:key="rIdx"
class="hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<td
v-if="rIdx === 0"
:rowspan="item.routes.length"
class="font-mono tabular-nums text-indigo-600 dark:text-indigo-400"
>
{{ item.ip }}
</td>
<td class="font-mono tabular-nums text-yellow-700 dark:text-yellow-300">{{ route.addr }}</td>
<td class="tabular-nums">{{ route.metric }}</td>
<td class="tabular-nums">{{ route.rtt }} ms</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
+215
View File
@@ -0,0 +1,215 @@
<script setup>
import { onMounted, reactive, ref } from "vue";
import AppSelect from "../components/AppSelect.vue";
const bridge = globalThis.__VNT_WEB_ACCESS__;
const draft = reactive({ enabled: false, port: 19099, global: false, token: "" });
const status = ref(null);
const loading = ref(true);
const saving = ref(false);
const notice = ref("");
const error = ref("");
const listenScopeOptions = [
{ value: false, label: "仅本机(推荐)" },
{ value: true, label: "局域网内所有设备" },
];
const sync = (value) => {
status.value = value;
Object.assign(draft, {
enabled: value.enabled,
port: value.port,
global: value.global,
token: value.token,
});
};
const load = async () => {
loading.value = true;
error.value = "";
try {
if (!bridge) throw new Error("Web 访问设置仅在桌面客户端中提供");
sync(await bridge.status());
} catch (err) {
error.value = err.message || String(err);
} finally {
loading.value = false;
}
};
const update = async (changes, successMessage) => {
saving.value = true;
error.value = "";
notice.value = "";
try {
sync(await bridge.update({
...draft,
...changes,
port: Number(changes.port ?? draft.port),
}));
notice.value = successMessage;
} catch (err) {
const message = err.message || String(err);
try {
sync(await bridge.status());
} catch {
//
}
error.value = message;
} finally {
saving.value = false;
}
};
const toggleService = async () => {
const enabled = !draft.enabled;
await update(
{ enabled },
enabled ? "Web 服务已启动" : "Web 服务已关闭",
);
};
const regenerate = async () => {
const token = await bridge.generateToken();
await update(
{ token },
draft.enabled ? "新令牌已生效,Web 服务已重新加载" : "新令牌已生成",
);
};
const saveNetworkSettings = async () => {
if (draft.enabled) return;
await update({}, "监听设置已自动保存");
};
const updateListenScope = async (value) => {
draft.global = value;
await saveNetworkSettings();
};
const copyUrl = async () => {
await navigator.clipboard.writeText(status.value.url);
notice.value = "访问地址已复制";
};
const openBrowser = async () => {
await bridge.openUrl(status.value.url);
};
onMounted(load);
</script>
<template>
<div class="mx-auto max-w-4xl space-y-5">
<div class="page-title">
<div>
<h2>Web 访问</h2>
<p>从浏览器访问当前 VNT 进程API 请求由持久访问令牌保护</p>
</div>
</div>
<div v-if="loading" class="card text-sm text-slate-400">正在读取 Web 服务状态</div>
<div v-else-if="!bridge" class="card border-red-200 text-sm text-red-600 dark:border-red-900 dark:text-red-300">{{ error }}</div>
<template v-else>
<section class="card space-y-6">
<div class="flex items-start justify-between gap-5">
<div>
<h3 class="text-sm font-semibold text-slate-900 dark:text-white">启用 Web 服务</h3>
</div>
<button
type="button"
role="switch"
aria-label="启用 Web 服务"
:aria-checked="draft.enabled"
:disabled="saving"
class="web-switch transition-colors disabled:opacity-50"
:class="draft.enabled ? 'bg-indigo-600 dark:bg-indigo-500' : 'bg-slate-300 dark:bg-slate-600'"
@click="toggleService"
>
<span class="web-switch-knob bg-white shadow-sm" :class="{ 'web-switch-knob-on': draft.enabled }"></span>
</button>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听端口</span>
<input v-model.number="draft.port" class="input font-mono" type="number" min="1" max="65535" :disabled="saving || draft.enabled" @change="saveNetworkSettings" />
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听范围</span>
<AppSelect
:model-value="draft.global"
:options="listenScopeOptions"
:disabled="saving || draft.enabled"
aria-label="监听范围"
@update:model-value="updateListenScope"
/>
</label>
</div>
<p class="-mt-3 text-xs text-slate-400">端口和监听范围会自动保存需要修改时请先关闭 Web 服务</p>
<div>
<div class="mb-2 flex items-center justify-between gap-3">
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">访问令牌</span>
<button class="text-xs font-medium text-indigo-600 hover:text-indigo-500 disabled:opacity-50 dark:text-indigo-400" type="button" :disabled="saving" @click="regenerate">更换令牌</button>
</div>
<code class="block min-w-0 truncate rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">{{ draft.token }}</code>
<p class="mt-2 text-xs text-slate-400">更换令牌后已登录的浏览器需要使用新令牌重新鉴权</p>
</div>
<div v-if="notice || error" class="border-t border-slate-200 pt-5 dark:border-slate-800">
<span v-if="notice" class="text-xs text-green-600 dark:text-green-400">{{ notice }}</span>
<span v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</span>
</div>
</section>
<section class="card">
<div class="flex items-start gap-3">
<span class="mt-1 h-2.5 w-2.5 shrink-0 rounded-full" :class="status?.running ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'"></span>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-slate-900 dark:text-white">{{ status?.running ? "运行中" : "未运行" }}</div>
<div class="mt-1 text-xs text-slate-400">监听地址{{ status?.listenAddress }}</div>
<div v-if="status?.running" class="mt-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 font-mono text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">
<span class="block truncate">{{ status.url }}</span>
</div>
<div v-if="status?.running" class="mt-3 flex flex-wrap gap-2">
<button class="btn-primary btn-sm" type="button" @click="openBrowser">打开浏览器</button>
<button class="btn-ghost btn-sm" type="button" @click="copyUrl">复制访问地址</button>
</div>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.web-switch {
position: relative;
display: inline-flex;
flex: 0 0 44px;
align-items: center;
width: 44px;
min-width: 44px;
height: 24px;
min-height: 24px;
padding: 2px;
border: 0;
border-radius: 9999px;
cursor: pointer;
}
.web-switch-knob {
display: block;
width: 20px;
min-width: 20px;
height: 20px;
border-radius: 9999px;
transform: translateX(0);
transition: transform 160ms ease;
}
.web-switch-knob-on {
transform: translateX(20px);
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
proxy: {
"/api": "http://127.0.0.1:19099",
},
},
build: {
outDir: "../static",
emptyOutDir: true,
},
});