diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cbe1cb7..4e40fbac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,10 +107,10 @@ jobs: extra-args: -F "standard" no-test: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: true - - uses: actions/cache@v5 + - uses: actions/cache@v6 with: path: | ~/.cargo/registry @@ -226,7 +226,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 75e96352..0bd8f6ab 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -53,7 +53,7 @@ jobs: CLASH_DOCKER_TEST: ${{ startsWith(matrix.os, 'ubuntu') && 'true' || 'false' }} - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: codecov.json diff --git a/.github/workflows/proxy-throughput-release.yml b/.github/workflows/proxy-throughput-release.yml index 8c019d5d..bd44bfd5 100644 --- a/.github/workflows/proxy-throughput-release.yml +++ b/.github/workflows/proxy-throughput-release.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/proxy-throughput.yml b/.github/workflows/proxy-throughput.yml index 24abf3db..838359aa 100644 --- a/.github/workflows/proxy-throughput.yml +++ b/.github/workflows/proxy-throughput.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afb73aea..f41b459b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.ADMIN_PAT }} diff --git a/.github/workflows/spell-check.yml b/.github/workflows/spell-check.yml index 8d48d116..b41db85b 100644 --- a/.github/workflows/spell-check.yml +++ b/.github/workflows/spell-check.yml @@ -14,6 +14,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Spell Check Repo - uses: crate-ci/typos@v1.46.0 + uses: crate-ci/typos@v1.50.1 diff --git a/NIXOS_MODULE.md b/NIXOS_MODULE.md new file mode 100644 index 00000000..fd19400c --- /dev/null +++ b/NIXOS_MODULE.md @@ -0,0 +1,779 @@ +# Chimera Client 可复用 NixOS 模块声明与发布指南 + +本文定义 Chimera Client 对外发布时推荐采用的 Nix package、NixOS +module、flake 输出和测试契约。目标是让用户不依赖 +`services.mihomo`,只需导入 Chimera Client 自己的模块即可声明式运行 +服务。 + +本文面向两个角色: + +- 项目维护者:在仓库中实现 package、module 和 NixOS VM 测试; +- 下游用户:从 flake、固定版本源码或 nixpkgs 包中启用服务。 + +## 1. 发布目标 + +建议最终仓库结构如下: + +```text +Chimera_Client/ +├── Cargo.toml +├── Cargo.lock +├── flake.nix +├── nix/ +│ ├── package.nix +│ ├── module.nix +│ └── tests/ +│ └── service.nix +└── NIXOS_MODULE.md +``` + +发布后的 flake 至少应提供: + +```nix +packages..chimera-client +packages..default +nixosModules.chimera-client +nixosModules.default +checks..package +checks..nixos-module +``` + +模块的稳定公共入口为: + +```nix +services.chimera-client +``` + +不要使用 `services.clash-rs` 作为长期接口。`clash-rs` 是当前二进制 +名称,而 `chimera-client` 才是软件和模块的发布身份。 + +## 2. 当前程序的 CLI 契约 + +当前二进制为 `clash-rs`,已经具备 NixOS 服务所需的基本接口: + +```bash +# 验证配置 +clash-rs \ + --directory /var/lib/private/chimera-client \ + --config /run/credentials/chimera-client.service/config.yaml \ + --test-config + +# 前台运行 +clash-rs \ + --directory /var/lib/private/chimera-client \ + --config /run/credentials/chimera-client.service/config.yaml + +# 输出版本 +clash-rs --version +``` + +注意: + +1. `--directory` 是状态和资源目录,不是配置文件所在目录; +2. `--config` 可以传绝对路径,因此可以直接读取 systemd credential; +3. `--test-config` 对应 systemd 的 `ExecStartPre`; +4. 程序必须保持前台运行,由 systemd 管理生命周期; +5. `cache.db`、下载的 MMDB、ASN MMDB、GeoSite 和 provider 数据应位于 + `--directory`; +6. 当前兼容模式默认开启。指定 `--directory` 时,程序会把该目录设为 + 工作目录,并为未指定的 MMDB/GeoSite 应用兼容默认值。 + +将来即使增加 `check`、`run` 子命令,也应至少在一个稳定发布周期内 +保留上述参数兼容性。 + +## 3. 推荐的公共 options + +第一版模块建议声明以下选项: + +| Option | 类型 | 默认值 | 说明 | +|---|---|---:|---| +| `enable` | `bool` | `false` | 启用 Chimera Client | +| `package` | `package` | flake 默认包 | 要运行的软件包 | +| `configFile` | `path` | 无 | 包含敏感信息的 YAML 配置 | +| `checkConfig` | `bool` | `true` | 启动前执行 `--test-config` | +| `tun.enable` | `bool` | `false` | 授予 TUN 和策略路由权限 | +| `processInspection` | `bool` | `false` | 允许按进程名称/路径匹配 | +| `extraArgs` | `listOf str` | `[]` | 追加的 CLI 参数 | +| `environment` | `attrsOf str` | `{}` | 服务环境变量 | +| `stateDirectory` | `str` | `"chimera-client"` | systemd 状态目录名 | +| `openFirewall` | `bool` | `false` | 预留选项,第一版建议不自动开放端口 | + +模块不应提供 `settings` 并把完整 YAML 生成到 Nix Store。代理节点密码、 +订阅 URL、控制器 secret 和证书路径通常属于敏感配置,应通过 +`configFile` 与 systemd credentials 加载。 + +`tun.enable` 只控制 systemd 权限和沙箱,不修改 YAML。用户仍需在 +配置文件中显式设置: + +```yaml +tun: + enable: true + route-all: true +``` + +## 4. 可复用的 NixOS module 模板 + +建议将以下内容保存为 `nix/module.nix`。其中 +`self.packages.${pkgs.system}.default` 的注入方式由 flake 决定;如果 +模块需要脱离 flake 单独使用,可要求用户显式设置 `package`。 + +```nix +{ + config, + lib, + pkgs, + utils, + ... +}: + +let + cfg = config.services.chimera-client; + + executable = lib.getExe cfg.package; + stateDirectory = "/var/lib/private/${cfg.stateDirectory}"; + credentialConfig = "\${CREDENTIALS_DIRECTORY}/config.yaml"; + + capabilities = + lib.optional cfg.tun.enable "CAP_NET_ADMIN" + ++ lib.optionals cfg.processInspection [ + "CAP_DAC_READ_SEARCH" + "CAP_SYS_PTRACE" + ]; + + commonArgs = [ + executable + "--directory" + stateDirectory + "--config" + credentialConfig + ]; + + startCommand = + utils.escapeSystemdExecArgs + (commonArgs ++ cfg.extraArgs); + + checkCommand = + utils.escapeSystemdExecArgs + (commonArgs ++ [ "--test-config" ]); +in +{ + options.services.chimera-client = { + enable = lib.mkEnableOption "Chimera Client rule-based proxy service"; + + package = lib.mkOption { + type = lib.types.package; + description = '' + Chimera Client package containing the clash-rs executable. + + The package must declare meta.mainProgram = "clash-rs". + ''; + }; + + configFile = lib.mkOption { + type = lib.types.path; + description = '' + Path to the Chimera Client YAML configuration. + + The file may contain credentials and is loaded through systemd + credentials. Do not generate secret configuration in the Nix Store. + ''; + }; + + checkConfig = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Validate the configuration with --test-config before starting. + ''; + }; + + tun.enable = lib.mkEnableOption '' + the privileges and sandbox exceptions required for TUN mode + ''; + + processInspection = lib.mkEnableOption '' + privileges required for process-name and process-path rules + ''; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "--controller-ipc" + "/run/chimera-client/controller.sock" + ]; + description = '' + Additional command-line arguments passed to Chimera Client. + + Do not put secrets in this option because command-line arguments are + observable through process metadata. + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + RUST_LOG = "info"; + }; + description = "Environment variables for the service."; + }; + + stateDirectory = lib.mkOption { + type = lib.types.strMatching "[A-Za-z0-9_.-]+"; + default = "chimera-client"; + description = '' + StateDirectory name used by systemd. Runtime data is stored below + /var/lib/private/ when DynamicUser is enabled. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether the module may open configured listener ports. + + The first module release should reject true until listener extraction + from external YAML is implemented safely. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = + lib.meta.availableOn + pkgs.stdenv.hostPlatform + cfg.package; + message = '' + services.chimera-client.package is not available on + ${pkgs.stdenv.hostPlatform.system}. + ''; + } + { + assertion = !cfg.openFirewall; + message = '' + services.chimera-client.openFirewall is not implemented yet. + Open explicit ports with networking.firewall.allowedTCPPorts and + networking.firewall.allowedUDPPorts. + ''; + } + ]; + + systemd.services.chimera-client = { + description = "Chimera Client rule-based proxy service"; + documentation = [ + "https://github.com/mfsga/Chimera_Client" + ]; + + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + ]; + + # Chimera currently invokes `ip` as a subprocess. + path = [ + pkgs.iproute2 + ]; + + environment = cfg.environment; + + serviceConfig = { + Type = "simple"; + ExecStartPre = lib.optional cfg.checkConfig checkCommand; + ExecStart = startCommand; + + Restart = "on-failure"; + RestartSec = "3s"; + TimeoutStopSec = "30s"; + KillSignal = "SIGTERM"; + + DynamicUser = true; + StateDirectory = cfg.stateDirectory; + LoadCredential = [ + "config.yaml:${cfg.configFile}" + ]; + UMask = "0077"; + + AmbientCapabilities = capabilities; + CapabilityBoundingSet = capabilities; + + NoNewPrivileges = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateTmp = true; + PrivateMounts = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectHostname = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RestrictNamespaces = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "bpf" + ]; + + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ] ++ lib.optional cfg.tun.enable "AF_NETLINK"; + + PrivateDevices = !cfg.tun.enable; + PrivateUsers = !(cfg.tun.enable || cfg.processInspection); + + ProtectProc = + if cfg.processInspection + then "default" + else "invisible"; + + ProcSubset = + if cfg.processInspection + then "all" + else "pid"; + }; + }; + }; + + meta.maintainers = [ ]; +} +``` + +### 4.1 为什么需要 `path` + +Chimera Client 当前不是只通过 Rust netlink API 管理系统,它还会调用: + +```text +ip +``` + +因此 unit 必须显式提供: + +```nix +path = [ + pkgs.iproute2 +]; +``` + +否则开发环境能够运行、systemd 服务却可能报 `No such file or +directory`。 + +### 4.2 为什么 TUN 模式不能照抄全部默认沙箱 + +TUN 模式需要: + +- `CAP_NET_ADMIN`; +- `AF_NETLINK`; +- 访问 `/dev/net/tun`; +- 创建、删除接口和策略路由。 + +Chimera 不修改 `systemd-resolved` 的 per-link DNS,也不需要 +`CAP_NET_BIND_SERVICE` 或 resolve1 Polkit 授权。 + +因此启用 `tun.enable` 时至少要设置: + +```nix +PrivateDevices = false; +PrivateUsers = false; +RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + "AF_NETLINK" +]; +``` + +不要仅添加 `CAP_NET_ADMIN` 后仍保持 `PrivateDevices = true`,否则服务 +可能拥有 capability,却看不到宿主的 TUN 设备。 + +### 4.3 进程识别权限 + +当 YAML 包含 `PROCESS-NAME`、`PROCESS-PATH` 等规则时,模块用户应设置: + +```nix +services.chimera-client.processInspection = true; +``` + +这会放宽 `/proc` 可见性,并授予: + +```text +CAP_DAC_READ_SEARCH +CAP_SYS_PTRACE +``` + +第一版发布前必须用真实进程匹配测试确认这些权限足够;如果不同内核的 +Yama、hidepid 或 LSM 仍阻止检查,应记录平台限制,而不是直接把服务 +改成 root 常驻。 + +## 5. Package 声明要求 + +`nix/package.nix` 应从源码构建,而不是包装开发机的 `target/debug` +二进制。包至少需要满足: + +```nix +meta = { + mainProgram = "clash-rs"; + platforms = lib.platforms.linux; +}; +``` + +建议使用 `rustPlatform.buildRustPackage`。示意模板: + +```nix +{ + lib, + rustPlatform, + pkg-config, + cmake, + protobuf, + llvmPackages, + ... +}: + +rustPlatform.buildRustPackage { + pname = "chimera-client"; + version = "0.23.0"; + + src = lib.cleanSource ../.; + + cargoLock = { + lockFile = ../Cargo.lock; + + # Cargo.lock 中 git dependencies 需要逐项提供 outputHashes。 + outputHashes = { + # "dependency-version" = "sha256-..."; + }; + }; + + nativeBuildInputs = [ + pkg-config + cmake + protobuf + llvmPackages.libclang + ]; + + LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; + + cargoBuildFlags = [ + "--package" + "clash-rs" + ]; + + cargoTestFlags = [ + "--package" + "clash-rs" + ]; + + meta = { + description = "Rust rule-based proxy client with DNS, TUN and Clash-compatible APIs"; + homepage = "https://github.com/mfsga/Chimera_Client"; + license = lib.licenses.gpl3Only; # 发布前按仓库真实许可证修正 + mainProgram = "clash-rs"; + platforms = lib.platforms.linux; + }; +} +``` + +上面的 `license` 和 `outputHashes` 是发布阻断项,不能保留猜测值。 +发布前必须: + +1. 在仓库根目录加入明确的许可证文件; +2. 为所有 Cargo git dependencies 固定 Nix output hash; +3. 确认 release 构建启用了需要的默认 features; +4. 确认最终包只安装需要发布的二进制和资源。 + +## 6. Flake 输出契约 + +当前仓库的 `flake.nix` 只提供本机开发 shell,并把 nixpkgs 输入绑定到 +本机 `/nix/store` 路径。这种写法不可发布。 + +公开发布时应将输入改为可复现的远端引用,例如: + +```nix +inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; +``` + +推荐输出结构: + +```nix +{ + description = "Chimera Client"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs, ... }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + ]; + + forAllSystems = + nixpkgs.lib.genAttrs supportedSystems + (system: + let + pkgs = import nixpkgs { inherit system; }; + in + pkgs); + in + { + packages = nixpkgs.lib.mapAttrs + (_: pkgs: + let + package = pkgs.callPackage ./nix/package.nix { }; + in + { + chimera-client = package; + default = package; + }) + forAllSystems; + + nixosModules.chimera-client = + { lib, pkgs, ... }: + { + imports = [ ./nix/module.nix ]; + + services.chimera-client.package = + lib.mkDefault + self.packages.${pkgs.system}.default; + }; + + nixosModules.default = self.nixosModules.chimera-client; + }; +} +``` + +这里展示的是输出契约,不应未经 `nix flake check` 就直接视为最终 +实现。实际实现还需要把 `pkgs` 的作用域正确传入 module wrapper,并 +加入 checks 和 devShell。 + +## 7. 下游使用示例 + +### 7.1 Flake 用户 + +```nix +{ + inputs.chimera-client.url = + "github:mfsga/Chimera_Client"; + + outputs = { nixpkgs, chimera-client, ... }: { + nixosConfigurations.desktop = + nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + chimera-client.nixosModules.default + + ({ config, lib, ... }: { + services.chimera-client = { + enable = true; + configFile = + config.age.secrets.chimera-client-config.path; + tun.enable = true; + processInspection = true; + checkConfig = true; + }; + + age.secrets.chimera-client-config.file = + ./secrets/chimera-client.yaml.age; + + networking.firewall.checkReversePath = + lib.mkDefault "loose"; + }) + ]; + }; + }; +} +``` + +### 7.2 不使用 secret manager + +```nix +services.chimera-client = { + enable = true; + package = inputs.chimera-client.packages.${pkgs.system}.default; + configFile = "/var/lib/secrets/chimera-client/config.yaml"; + tun.enable = true; +}; +``` + +配置文件应由 root 安装: + +```bash +sudo install -Dm600 config.yaml \ + /var/lib/secrets/chimera-client/config.yaml +``` + +不要将含凭据的 YAML 写入: + +```nix +environment.etc."chimera-client/config.yaml".text = "..."; +``` + +## 8. TUN 与网络安全约束 + +### 8.1 反向路径过滤 + +策略路由和 TUN 可能与严格 rpfilter 冲突。主机配置通常需要: + +```nix +networking.firewall.checkReversePath = "loose"; +``` + +模块第一版不建议静默覆盖全局防火墙策略。可以: + +- 在文档中要求用户设置; +- 或增加一个显式 option,由用户授权后设置 `mkDefault "loose"`。 + +### 8.2 TUN 与 Fake-IP 网段 + +推荐默认布局: + +```text +TUN link: 198.18.0.1/30 +Fake-IP: 198.19.0.0/16 +``` + +两个网段必须分离。Chimera Client 在 Fake-IP 模式和非 +`route-all` 模式下会自动补充 Fake-IP 到 TUN 的路由;显式重叠配置 +应在启动前验证阶段失败。 + +### 8.3 远程部署与回退 + +启用 TUN 可能切断当前 SSH、Codex 或构建代理连接。远程部署应: + +1. 先执行 `nixos-rebuild build`; +2. 使用 `nixos-rebuild test` 或带自动回退的部署工具; +3. 保留物理局域网和管理端点的 main-table 绕行规则; +4. 确认 `SIGTERM` 能清理 TUN 和 policy rules; +5. 在另一个终端持续检查默认路由和远程连接; +6. 不要在未验证备用通道时直接切换生产主机。 + +备用代理、SSH 跳板或带外管理地址属于部署环境信息,不应硬编码进 +通用 module。下游部署者应在主机配置中显式维护这些绕行地址。 + +## 9. NixOS VM 测试要求 + +建议 `nix/tests/service.nix` 至少覆盖: + +1. module 能成功 evaluation; +2. package 的 `meta.mainProgram` 可解析; +3. credential 文件没有被复制成普通公开配置; +4. `ExecStartPre` 会接受合法配置; +5. 非法配置导致 unit 启动失败; +6. `StateDirectory` 可写,能够创建 `cache.db`; +7. 非 TUN 模式不拥有 `CAP_NET_ADMIN`; +8. TUN 模式包含 `CAP_NET_ADMIN` 和 `AF_NETLINK`; +9. unit PATH 中能找到 `ip`,且不依赖 `resolvectl`; +10. 服务收到 `SIGTERM` 后正常退出; +11. 重启后没有重复 policy rule; +12. 条件允许时,验证 TUN 和 Fake-IP 使用分离网段。 + +最小测试形状: + +```nix +import "${pkgs.path}/nixos/tests/make-test-python.nix" ({ + name = "chimera-client"; + + nodes.machine = { config, ... }: { + imports = [ ../module.nix ]; + + services.chimera-client = { + enable = true; + package = packageUnderTest; + configFile = ./fixtures/minimal.yaml; + checkConfig = true; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("chimera-client.service") + machine.succeed("systemctl is-active chimera-client.service") + machine.succeed( + "systemctl show chimera-client.service " + "-p StateDirectory -p DynamicUser" + ) + ''; +}) +``` + +TUN 测试应单独建立测试项,避免普通 module evaluation 因内核权限或 +网络副作用变得不稳定。 + +## 10. 发布检查清单 + +### Package + +- [ ] 仓库有明确许可证; +- [ ] Cargo.lock 已提交; +- [ ] 所有 git dependencies 有固定 hash; +- [ ] `nix build .#chimera-client` 成功; +- [ ] `nix run . -- --version` 成功; +- [ ] `meta.mainProgram = "clash-rs"`; +- [ ] x86_64-linux 构建通过; +- [ ] aarch64-linux 至少完成 evaluation,最好有真实构建。 + +### Module + +- [ ] `services.chimera-client.enable`; +- [ ] package 可覆盖; +- [ ] configFile 使用 credential; +- [ ] 状态文件写入 StateDirectory; +- [ ] `ExecStartPre` 验证配置; +- [ ] TUN 权限只在明确启用时授予; +- [ ] process inspection 权限只在明确启用时授予; +- [ ] `ip` 位于 unit PATH,unit 不依赖 `resolvectl`; +- [ ] SIGTERM 能清理系统状态; +- [ ] 无效配置不会进入重启风暴。 + +### Tests + +- [ ] `nix flake check`; +- [ ] 非 TUN 服务测试; +- [ ] 无效配置测试; +- [ ] capability 断言; +- [ ] 状态目录写入测试; +- [ ] TUN 独立 VM 测试; +- [ ] restart/reload 幂等性测试。 + +### Documentation + +- [ ] README 提供 flake 导入示例; +- [ ] options 有稳定名称和说明; +- [ ] 说明配置包含秘密; +- [ ] 说明 rpfilter; +- [ ] 说明 TUN/Fake-IP 网段; +- [ ] 说明远程部署回退方案; +- [ ] 标明支持的 NixOS/nixpkgs 版本。 + +## 11. 与官方实践的关系 + +本设计沿用 NixOS 官方推荐的 module 结构: + +- 使用 `options` 声明带类型的公共接口; +- 使用 `config = lib.mkIf cfg.enable` 生成 systemd 配置; +- package 允许由用户覆盖; +- 使用 `LoadCredential` 避免秘密进入普通命令行和公开配置; +- 使用 `DynamicUser` 与 `StateDirectory` 管理服务身份和可写状态; +- 使用 NixOS VM tests 验证模块行为。 + +Chimera Client 当前仍会主动调用 `ip` 管理 Linux policy routing,但 +不会调用 `resolvectl` 或管理 per-link DNS。该差异必须体现在 unit +PATH、TUN 沙箱和测试中。 + +官方参考: + +- [NixOS Manual: Writing NixOS Modules](https://nixos.org/manual/nixos/stable/index.html#sec-writing-modules) +- [NixOS Manual: NixOS Tests](https://nixos.org/manual/nixos/stable/index.html#sec-nixos-tests) +- [Nixpkgs Mihomo module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/networking/mihomo.nix) +- [Nixpkgs Reference Manual](https://nixos.org/manual/nixpkgs/stable/) diff --git a/clash-dns/src/handler.rs b/clash-dns/src/handler.rs index 47d6e1bb..0713c8c0 100644 --- a/clash-dns/src/handler.rs +++ b/clash-dns/src/handler.rs @@ -528,10 +528,7 @@ mod tests { use hickory_proto::rr::{DNSClass, Name, RData, RecordType}; use rustls::{ClientConfig, pki_types::ServerName}; use std::{sync::Arc, time::Duration}; - use tokio::{ - net::{TcpListener, UdpSocket}, - task::JoinHandle, - }; + use tokio::net::{TcpListener, UdpSocket}; async fn send_query( client: &mut Client, @@ -635,10 +632,10 @@ mod tests { super::get_dns_listener(cfg, mock_exchanger, std::path::Path::new(".")) .await; assert!(listener.is_some()); - let _: JoinHandle> = tokio::spawn(async move { + std::mem::drop(tokio::spawn(async move { listener.unwrap().await?; - Ok(()) - }); + Ok::<(), anyhow::Error>(()) + })); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/clash-lib/src/app/dispatcher/dispatcher_impl.rs b/clash-lib/src/app/dispatcher/dispatcher_impl.rs index a9d09084..9668506e 100644 --- a/clash-lib/src/app/dispatcher/dispatcher_impl.rs +++ b/clash-lib/src/app/dispatcher/dispatcher_impl.rs @@ -113,7 +113,7 @@ impl Dispatcher { RunMode::Direct => (PROXY_DIRECT, None), }; - let rule_summary = rule_summary(rule); + let rule_summary = rule_summary(rule.map(Box::as_ref)); debug!("dispatching {} to {}[{}]", sess, outbound_name, mode); let mgr = self.outbound_manager.clone(); @@ -354,7 +354,7 @@ impl Dispatcher { outbound_name }; - let rule_summary = rule_summary(rule); + let rule_summary = rule_summary(rule.map(Box::as_ref)); debug!( outbound_name = %outbound_name, rule = %rule_summary, @@ -620,7 +620,7 @@ async fn reverse_lookup( Some(dst) } -fn rule_summary(rule: Option<&Box>) -> String { +fn rule_summary(rule: Option<&dyn crate::app::router::RuleMatcher>) -> String { rule.map(|rule| { let payload = rule.payload(); if payload.is_empty() { @@ -807,6 +807,7 @@ impl OutboundHandleMap { } } +#[allow(clippy::items_after_test_module)] #[cfg(test)] mod tests { use super::{OutboundHandleMap, try_queue_outbound_packet}; diff --git a/clash-lib/src/app/dns/dns_client.rs b/clash-lib/src/app/dns/dns_client.rs index 68834747..3929ed55 100644 --- a/clash-lib/src/app/dns/dns_client.rs +++ b/clash-lib/src/app/dns/dns_client.rs @@ -25,7 +25,7 @@ use hickory_proto::{ }; use rustls::{ClientConfig, pki_types::ServerName}; use tokio::{sync::RwLock, task::JoinHandle}; -use tracing::{info, instrument, trace, warn}; +use tracing::{debug, info, instrument, trace, warn}; use crate::{ Error, @@ -63,7 +63,7 @@ impl Display for DNSNetMode { #[cfg(test)] mod tests { use super::*; - use crate::proxy; + use crate::{app::dns::MockClashResolver, proxy}; use hickory_proto::{ op, rr::{Name, rdata::opt::EdnsOption}, @@ -78,8 +78,9 @@ mod tests { c: None, bg_handle: None, })), - cfg: DnsConfig::Udp(addr, None, proxy.clone(), None), + cfg: RwLock::new(DnsConfig::Udp(addr, None, proxy.clone(), None)), proxy, + bootstrap_resolver: None, host: url::Host::Domain("example.org".to_string()), port: 53, net: DNSNetMode::Udp, @@ -89,6 +90,28 @@ mod tests { } } + #[tokio::test] + async fn refresh_upstream_address_uses_bootstrap_resolver() { + let mut resolver = MockClashResolver::new(); + resolver + .expect_resolve() + .with( + mockall::predicate::eq("example.org"), + mockall::predicate::eq(false), + ) + .once() + .returning(|_, _| Ok(Some(net::IpAddr::from([203, 0, 113, 10])))); + + let mut client = client_with_ecs(None); + client.bootstrap_resolver = Some(Arc::new(resolver)); + + assert!(client.refresh_upstream_address().await.unwrap()); + assert_eq!( + client.cfg.read().await.addr(), + net::SocketAddr::from(([203, 0, 113, 10], 53)) + ); + } + fn build_message(record_type: RecordType) -> Message { let mut msg = Message::new( 0, @@ -226,6 +249,7 @@ pub struct Opts { type FwMark = Option; +#[derive(Clone)] enum DnsConfig { Udp( net::SocketAddr, @@ -255,6 +279,26 @@ enum DnsConfig { ), } +impl DnsConfig { + fn addr(&self) -> net::SocketAddr { + match self { + Self::Udp(addr, ..) + | Self::Tcp(addr, ..) + | Self::Tls(addr, ..) + | Self::Https(addr, ..) => *addr, + } + } + + fn set_ip(&mut self, ip: IpAddr) { + match self { + Self::Udp(addr, ..) + | Self::Tcp(addr, ..) + | Self::Tls(addr, ..) + | Self::Https(addr, ..) => addr.set_ip(ip), + } + } +} + impl Display for DnsConfig { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match &self { @@ -303,8 +347,9 @@ struct Inner { pub struct DnsClient { inner: Arc>, - cfg: DnsConfig, + cfg: RwLock, proxy: Arc, + bootstrap_resolver: Option>, // debug purpose host: url::Host, @@ -316,53 +361,111 @@ pub struct DnsClient { } impl DnsClient { - /// Rebuild the DNS stream with retries, waiting between attempts. - /// Network transitions can briefly make outbound sockets unavailable; a - /// short retry loop avoids permanently failing the resolver on a transient - /// rebuild error. - async fn rebuild_with_retries( + async fn build_stream( &self, - ) -> anyhow::Result<(client::Client, JoinHandle<()>)> { + ) -> Result<(client::Client, JoinHandle<()>), Error> { + let cfg = self.cfg.read().await.clone(); + dns_stream_builder(&cfg, self.rule_dispatch.clone()).await + } + + async fn refresh_upstream_address(&self) -> anyhow::Result { + let url::Host::Domain(domain) = &self.host else { + return Ok(false); + }; + let Some(resolver) = &self.bootstrap_resolver else { + return Ok(false); + }; + let Some(ip) = resolver.resolve(domain, false).await? else { + return Err(Error::DNSError(format!( + "unable to refresh DNS upstream address for {domain}" + )) + .into()); + }; + + let mut cfg = self.cfg.write().await; + let old_addr = cfg.addr(); + if old_addr.ip() == ip { + debug!( + upstream = %self.id(), + address = %old_addr, + "DNS upstream address refresh returned the current address" + ); + return Ok(false); + } + + cfg.set_ip(ip); + info!( + upstream = %self.id(), + old_address = %old_addr, + new_address = %cfg.addr(), + "refreshed DNS upstream address after connection failures" + ); + Ok(true) + } + + async fn rebuild_current_address( + &self, + address_refreshed: bool, + ) -> Result<(client::Client, JoinHandle<()>), Error> { const MAX_RETRIES: u32 = 3; const RETRY_DELAY: Duration = Duration::from_millis(200); for attempt in 0..=MAX_RETRIES { - match dns_stream_builder(&self.cfg, self.rule_dispatch.clone()).await { + match self.build_stream().await { Ok(result) => { if attempt > 0 { info!( - "{}: dns client rebuild succeeded on attempt {}/{}", - self.id(), - attempt + 1, - MAX_RETRIES + 1 + upstream = %self.id(), + address_refreshed, + attempt = attempt + 1, + max_attempts = MAX_RETRIES + 1, + "dns client rebuild succeeded" ); } return Ok(result); } Err(err) if attempt < MAX_RETRIES => { warn!( - "{}: dns client rebuild attempt {}/{} failed: {err:#}, retrying in {}ms", - self.id(), - attempt + 1, - MAX_RETRIES + 1, - RETRY_DELAY.as_millis() + upstream = %self.id(), + address_refreshed, + attempt = attempt + 1, + max_attempts = MAX_RETRIES + 1, + retry_delay_ms = RETRY_DELAY.as_millis(), + error = %err, + "dns client rebuild failed, retrying" ); tokio::time::sleep(RETRY_DELAY).await; } - Err(err) => { - warn!( - "{}: dns client rebuild failed after {} attempts: {err:#}", - self.id(), - MAX_RETRIES + 1 - ); - return Err(err.into()); - } + Err(err) => return Err(err), } } unreachable!() } + /// Rebuild the DNS stream with retries, waiting between attempts. + /// Network transitions can briefly make outbound sockets unavailable; a + /// short retry loop avoids permanently failing the resolver on a transient + /// rebuild error. + async fn rebuild_with_retries( + &self, + ) -> anyhow::Result<(client::Client, JoinHandle<()>)> { + let err = match self.rebuild_current_address(false).await { + Ok(result) => return Ok(result), + Err(err) => err, + }; + warn!( + upstream = %self.id(), + error = %err, + "dns client rebuild attempts exhausted, refreshing upstream address" + ); + if !self.refresh_upstream_address().await? { + return Err(err.into()); + } + + self.rebuild_current_address(true).await.map_err(Into::into) + } + pub async fn new_client(opts: Opts) -> anyhow::Result { // TODO: use proxy to connect? @@ -385,7 +488,7 @@ impl DnsClient { }; let resolved_ip = match need_resolve { - Some(domain) => match opts.father { + Some(domain) => match opts.father.as_ref() { Some(father) => match father.resolve(domain, false).await? { Some(ip) => Some(ip), _ => { @@ -427,8 +530,9 @@ impl DnsClient { c: None, bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -450,8 +554,9 @@ impl DnsClient { bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -473,8 +578,9 @@ impl DnsClient { c: None, bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -497,8 +603,9 @@ impl DnsClient { bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -612,7 +719,7 @@ impl Client for DnsClient { } _ => { // initializing client - info!("initializing dns client: {}", &self.cfg); + info!("initializing dns client: {}", self.cfg.read().await); let (client, bg) = self.rebuild_with_retries().await?; inner.c.replace(client); inner.bg_handle.replace(bg); diff --git a/clash-lib/src/app/dns/fakeip/mod.rs b/clash-lib/src/app/dns/fakeip/mod.rs index 2e92794b..11806791 100644 --- a/clash-lib/src/app/dns/fakeip/mod.rs +++ b/clash-lib/src/app/dns/fakeip/mod.rs @@ -33,11 +33,11 @@ pub trait Store: Sync + Send { pub type ThreadSafeFakeDns = Arc>; pub struct FakeDns { - max: u32, - min: u32, + first: u32, + capacity: u32, #[allow(dead_code)] gateway: u32, - offset: u32, + cursor: u32, skipped_hostnames: Option>, ipnet: ipnet::IpNet, store: Box, @@ -45,26 +45,33 @@ pub struct FakeDns { impl FakeDns { pub fn new(opt: Opts) -> Result { - let ip = match opt.ipnet.network() { - net::IpAddr::V4(ip) => ip, - _ => unreachable!("fakeip range must be valid ipv4 subnet"), + let network = match opt.ipnet { + ipnet::IpNet::V4(network) => network, + ipnet::IpNet::V6(_) => { + return Err(Error::InvalidConfig( + "fake-ip-range must be an IPv4 subnet".to_string(), + )); + } }; - // avoid tun gateway and its subnet broadcast - let min = Self::ip_to_uint(&ip) + 8; - let prefix_len = opt.ipnet.prefix_len(); - let max_prefix_len = opt.ipnet.max_prefix_len(); - debug_assert_eq!(max_prefix_len, 32, "v4 subnet"); - - // do not allocate the last 16 IPs in the range, to avoid broadcast and multicast addresses. - let total = (1 << (max_prefix_len - prefix_len)) - 16; + if network.prefix_len() > 30 { + return Err(Error::InvalidConfig( + "fake-ip-range must contain a network address, a gateway, at \ + least one allocatable address, and a broadcast address" + .to_string(), + )); + } - let max = min + total - 1; + let network_addr = Self::ip_to_uint(&network.network()); + let broadcast = Self::ip_to_uint(&network.broadcast()); + let gateway = network_addr + 1; + let first = network_addr + 2; + let capacity = broadcast - first; Ok(Self { - max, - min, - gateway: min - 1, - offset: 0, + first, + capacity, + gateway, + cursor: 0, skipped_hostnames: opt.skipped_hostnames, ipnet: opt.ipnet, store: opt.store, @@ -155,32 +162,26 @@ impl FakeDns { } async fn get(&mut self, host: &str) -> net::IpAddr { - let current = self.offset; - - loop { - self.offset = (self.offset + 1) % (self.max - self.min); - - if self.offset == current { - self.offset = (self.offset + 1) % (self.max - self.min); - let ip = net::Ipv4Addr::from(self.min + self.offset - 1); - info!( - fake_ip = %ip, - range = %self.ipnet, - "fake-ip pool full, evicting previous mapping" - ); - self.store.del_by_ip(std::net::IpAddr::V4(ip)).await; - break; - } - - let ip = net::Ipv4Addr::from(self.min + self.offset - 1); - if !self.store.exist(std::net::IpAddr::V4(ip)).await { - break; + for _ in 0..self.capacity { + let ip = net::Ipv4Addr::from(self.first + self.cursor); + self.cursor = (self.cursor + 1) % self.capacity; + if !self.store.exist(net::IpAddr::V4(ip)).await { + self.store.put_by_ip(net::IpAddr::V4(ip), host).await; + return net::IpAddr::V4(ip); } } - let ip = net::Ipv4Addr::from(self.min + self.offset - 1); - self.store.put_by_ip(std::net::IpAddr::V4(ip), host).await; - std::net::IpAddr::V4(ip) + // The pool is full. Reuse the oldest candidate selected by the cursor. + let ip = net::Ipv4Addr::from(self.first + self.cursor); + self.cursor = (self.cursor + 1) % self.capacity; + self.store.del_by_ip(net::IpAddr::V4(ip)).await; + info!( + fake_ip = %ip, + range = %self.ipnet, + "fake-ip pool full, evicting previous mapping" + ); + self.store.put_by_ip(net::IpAddr::V4(ip), host).await; + net::IpAddr::V4(ip) } fn ip_to_uint(ip: &net::Ipv4Addr) -> u32 { @@ -227,6 +228,62 @@ mod tests { assert!(!pool.exist("::1".parse().unwrap()).await); } + #[tokio::test] + async fn test_allocates_every_usable_address_without_gateway_or_broadcast() { + let store = Box::new(InMemStore::new(10)); + let mut pool = FakeDns::new(Opts { + ipnet: "192.168.0.0/29".parse().unwrap(), + skipped_hostnames: None, + store, + }) + .unwrap(); + + let mut allocated = Vec::new(); + for index in 0..5 { + allocated.push(pool.lookup(&format!("{index}.example")).await); + } + + assert_eq!( + allocated, + [ + "192.168.0.2", + "192.168.0.3", + "192.168.0.4", + "192.168.0.5", + "192.168.0.6" + ] + .map(|ip| ip.parse::().unwrap()) + ); + } + + #[tokio::test] + async fn test_30_pool_allocates_its_single_usable_address() { + let store = Box::new(InMemStore::new(10)); + let mut pool = FakeDns::new(Opts { + ipnet: "192.168.0.0/30".parse().unwrap(), + skipped_hostnames: None, + store, + }) + .unwrap(); + + assert_eq!( + pool.lookup("example.com").await, + "192.168.0.2".parse::().unwrap() + ); + } + + #[test] + fn test_rejects_too_small_or_ipv6_pool() { + for ipnet in ["192.168.0.0/31", "192.168.0.1/32", "fd00::/64"] { + let result = FakeDns::new(Opts { + ipnet: ipnet.parse().unwrap(), + skipped_hostnames: None, + store: Box::new(InMemStore::new(10)), + }); + assert!(result.is_err(), "{ipnet} must be rejected"); + } + } + #[tokio::test] async fn test_inmem_cycle_used() { let store = Box::new(InMemStore::new(10)); diff --git a/clash-lib/src/app/dns/resolver/enhanced.rs b/clash-lib/src/app/dns/resolver/enhanced.rs index 88f8bdf1..4fcd3c88 100644 --- a/clash-lib/src/app/dns/resolver/enhanced.rs +++ b/clash-lib/src/app/dns/resolver/enhanced.rs @@ -335,12 +335,22 @@ impl EnhancedResolver { let client_id = c.id(); c.exchange(message) .inspect_err(|x| { - error!( - client = %client_id, - query = %query_name, - record_type = %query_type, - err = ?x, - "resolve error"); + if x.to_string().contains("receiver was canceled") { + debug!( + client = %client_id, + query = %query_name, + record_type = %query_type, + "dns upstream query canceled after another response completed" + ); + } else { + error!( + client = %client_id, + query = %query_name, + record_type = %query_type, + err = ?x, + "resolve error" + ); + } }) .inspect_ok(|response| { let ips = Self::ip_list_of_message(response) diff --git a/clash-lib/src/app/dns/resolver/system.rs b/clash-lib/src/app/dns/resolver/system.rs index 65d905b2..630da5c8 100644 --- a/clash-lib/src/app/dns/resolver/system.rs +++ b/clash-lib/src/app/dns/resolver/system.rs @@ -135,7 +135,7 @@ mod tests { #[tokio::test] async fn test_system_resolver_default_config() { let resolver = SystemResolver::new(false).unwrap(); - let response = resolver.resolve("www.google.com", false).await.unwrap(); - assert!(response.is_some()); + let response = resolver.resolve("localhost", false).await.unwrap(); + assert!(response.is_some_and(|ip| ip.is_ipv4())); } } diff --git a/clash-lib/src/app/dns/server/mod.rs b/clash-lib/src/app/dns/server/mod.rs index 981f913a..311f5e1f 100644 --- a/clash-lib/src/app/dns/server/mod.rs +++ b/clash-lib/src/app/dns/server/mod.rs @@ -1,13 +1,7 @@ use futures::FutureExt; use hickory_proto::op::Message; -#[cfg(target_os = "linux")] -use network_interface::NetworkInterfaceConfig; use chimera_dns::DNSListenAddr; -use std::sync::{Arc, Mutex}; -#[allow(unused_mut)] -#[cfg(target_os = "linux")] -use std::{net::IpAddr, time::Duration}; use tracing::{error, info, instrument}; @@ -43,9 +37,6 @@ pub struct DnsRunner { listener: DNSListenAddr, resolver: ThreadSafeDNSResolver, cwd: std::path::PathBuf, - manage_system_resolver: bool, - managed_resolv_conf_backup: Arc>>, - managed_resolved_link: Arc>>, cancellation_token: tokio_util::sync::CancellationToken, task: std::sync::Mutex>>, @@ -57,7 +48,6 @@ impl DnsRunner { listen: DNSListenAddr, resolver: ThreadSafeDNSResolver, cwd: &std::path::Path, - manage_system_resolver: bool, cancellation_token: Option, ) -> Self { Self { @@ -65,389 +55,39 @@ impl DnsRunner { listener: listen, resolver, cwd: cwd.to_path_buf(), - manage_system_resolver, - managed_resolv_conf_backup: Arc::new(Mutex::new(None)), - managed_resolved_link: Arc::new(Mutex::new(None)), cancellation_token: cancellation_token.unwrap_or_default(), task: std::sync::Mutex::new(None), } } } -#[cfg(target_os = "linux")] -const MANAGED_RESOLV_CONF_MARKER: &str = - "# managed by Chimera Client while tun dns-hijack is active"; - -#[cfg(target_os = "linux")] -const DNS_BIND_TARGET_READY_MAX_ATTEMPTS: u32 = 100; - -#[cfg(target_os = "linux")] -const DNS_BIND_TARGET_READY_POLL_INTERVAL_MS: u64 = 50; - -#[cfg(target_os = "linux")] -fn linux_listener_target_ips(listen: &DNSListenAddr) -> Vec { - let mut targets = Vec::new(); - - for ip in listen - .udp - .iter() - .map(|addr| addr.ip()) - .chain(listen.tcp.iter().map(|addr| addr.ip())) - .chain(listen.doh.iter().map(|cfg| cfg.addr.ip())) - .chain(listen.dot.iter().map(|cfg| cfg.addr.ip())) - .chain(listen.doh3.iter().map(|cfg| cfg.addr.ip())) - { - if !targets.contains(&ip) { - targets.push(ip); - } - } - - targets -} - -#[cfg(target_os = "linux")] -fn find_interface_name_by_ip(ip: IpAddr) -> Result, std::io::Error> { - let interfaces = network_interface::NetworkInterface::show() - .map_err(|err| std::io::Error::other(err.to_string()))?; - - for iface in interfaces { - for addr in iface.addr { - match (ip, addr) { - (IpAddr::V4(target), network_interface::Addr::V4(addr)) - if addr.ip == target => - { - return Ok(Some(iface.name)); - } - (IpAddr::V6(target), network_interface::Addr::V6(addr)) - if addr.ip == target => - { - return Ok(Some(iface.name)); - } - _ => {} - } - } - } - - Ok(None) -} - -#[cfg(target_os = "linux")] -async fn wait_for_linux_dns_listener_targets( - listen: &DNSListenAddr, - cancellation_token: &tokio_util::sync::CancellationToken, -) -> bool { - let targets = linux_listener_target_ips(listen) - .into_iter() - .filter(|ip| !ip.is_loopback()) - .collect::>(); - - if targets.is_empty() { - return true; - } - - for _ in 0..DNS_BIND_TARGET_READY_MAX_ATTEMPTS { - let mut ready = true; - for target in &targets { - match find_interface_name_by_ip(*target) { - Ok(Some(_)) => {} - Ok(None) => { - ready = false; - break; - } - Err(err) => { - error!( - "failed to inspect interfaces while waiting for dns bind target {}: {}", - target, err - ); - ready = false; - break; - } - } - } - - if ready { - return true; - } - - tokio::select! { - _ = cancellation_token.cancelled() => return false, - _ = tokio::time::sleep(Duration::from_millis( - DNS_BIND_TARGET_READY_POLL_INTERVAL_MS, - )) => {} - } - } - - error!( - "dns listener bind targets never became ready: {:?}", - targets - ); - false -} - -#[cfg(not(target_os = "linux"))] -async fn wait_for_linux_dns_listener_targets( - _: &DNSListenAddr, - _: &tokio_util::sync::CancellationToken, -) -> bool { - true -} - -#[cfg(target_os = "linux")] -fn run_resolvectl(args: &[&str]) -> Result<(), std::io::Error> { - let output = std::process::Command::new("resolvectl") - .args(args) - .output()?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - let details = if stderr.is_empty() { stdout } else { stderr }; - - Err(std::io::Error::other(format!( - "resolvectl {} failed: {}", - args.join(" "), - details - ))) -} - -#[cfg(target_os = "linux")] -async fn maybe_take_over_linux_resolved_link( - listen: &DNSListenAddr, - managed_link: &Arc>>, - enabled: bool, -) -> bool { - if !enabled { - return false; - } - - let Some(addr) = listen.udp else { - return false; - }; - - if addr.port() != 53 || addr.ip().is_loopback() { - return false; - } - - let link_name = match find_interface_name_by_ip(addr.ip()) { - Ok(Some(link_name)) => link_name, - Ok(None) => { - info!( - "linux per-link dns takeover skipped because no interface owns {}", - addr.ip() - ); - return false; - } - Err(err) => { - error!( - "failed to resolve interface for linux per-link dns takeover: {}", - err - ); - return false; - } - }; - - let dns_ip = addr.ip().to_string(); - let commands = [ - vec!["dns", link_name.as_str(), dns_ip.as_str()], - vec!["domain", link_name.as_str(), "~."], - vec!["default-route", link_name.as_str(), "yes"], - vec!["llmnr", link_name.as_str(), "no"], - vec!["mdns", link_name.as_str(), "no"], - vec!["dnssec", link_name.as_str(), "no"], - vec!["dnsovertls", link_name.as_str(), "no"], - ]; - - for command in &commands { - if let Err(err) = run_resolvectl(command) { - error!( - "failed to configure systemd-resolved per-link dns on {}: {}", - link_name, err - ); - let _ = run_resolvectl(&["revert", link_name.as_str()]); - return false; - } - } - - { - let mut guard = managed_link.lock().unwrap(); - *guard = Some(link_name.clone()); - } - - info!( - "configured systemd-resolved per-link dns on {} via {}", - link_name, addr - ); - true -} - -#[cfg(target_os = "linux")] -async fn maybe_take_over_linux_stub_resolver( - listen: &DNSListenAddr, - backup: &Arc>>, - managed_link: &Arc>>, - enabled: bool, -) { - if !enabled { - return; - } - - if maybe_take_over_linux_resolved_link(listen, managed_link, enabled).await { - return; - } - - let Some(addr) = listen.udp else { - return; - }; - - if addr.port() != 53 { - return; - } - - let path = "/etc/resolv.conf"; - let current = match tokio::fs::read_to_string(path).await { - Ok(current) => current, - Err(err) => { - error!("failed to read {}: {}", path, err); - return; - } - }; - - if current.contains(MANAGED_RESOLV_CONF_MARKER) { - info!("linux stub resolver takeover is already active"); - return; - } - - if !current.contains("127.0.0.53") - && !current.contains("managed by man:systemd-resolved") - { - info!( - "linux stub resolver takeover skipped because /etc/resolv.conf is not using systemd-resolved stub" - ); - return; - } - - { - let mut guard = backup.lock().unwrap(); - if guard.is_none() { - *guard = Some(current.clone()); - } - } - - let replacement = format!( - "{MANAGED_RESOLV_CONF_MARKER}\nnameserver {}\noptions edns0 trust-ad\nsearch .\n", - addr.ip() - ); - - match tokio::fs::write(path, replacement).await { - Ok(()) => { - info!( - "temporarily redirected linux stub resolver to local dns listener {}", - addr - ); - } - Err(err) => { - error!("failed to update {}: {}", path, err); - } - } -} - -#[cfg(not(target_os = "linux"))] -async fn maybe_take_over_linux_stub_resolver( - _: &DNSListenAddr, - _: &Arc>>, - _: &Arc>>, - _: bool, -) { -} - -#[cfg(target_os = "linux")] -async fn maybe_restore_linux_stub_resolver( - backup: &Arc>>, - managed_link: &Arc>>, -) { - if let Some(link_name) = { - let mut guard = managed_link.lock().unwrap(); - guard.take() - } { - match run_resolvectl(&["revert", link_name.as_str()]) { - Ok(()) => { - info!( - "restored original systemd-resolved per-link dns configuration for {}", - link_name - ); - } - Err(err) => { - error!( - "failed to revert systemd-resolved per-link dns for {}: {}", - link_name, err - ); - } - } - } - - let previous = { - let mut guard = backup.lock().unwrap(); - guard.take() - }; - - let Some(previous) = previous else { - return; - }; - - let path = "/etc/resolv.conf"; - match tokio::fs::write(path, previous).await { - Ok(()) => { - info!("restored original linux stub resolver configuration"); - } - Err(err) => { - error!("failed to restore {}: {}", path, err); - } - } -} - -#[cfg(not(target_os = "linux"))] -async fn maybe_restore_linux_stub_resolver( - _: &Arc>>, - _: &Arc>>, -) { -} - impl Runner for DnsRunner { fn run_async(&self) { if !self.enable { info!("dns listener is disabled, skipping"); return; } + if self.listener.udp.is_none() + && self.listener.tcp.is_none() + && self.listener.doh.is_none() + && self.listener.dot.is_none() + && self.listener.doh3.is_none() + { + info!( + "dns listener is not configured; internal resolver remains available" + ); + return; + } let resolver = self.resolver.clone(); let listen = self.listener.clone(); - let listen_for_server = listen.clone(); let cwd = self.cwd.clone(); - let manage_system_resolver = self.manage_system_resolver; - let managed_resolv_conf_backup = self.managed_resolv_conf_backup.clone(); - let managed_resolved_link = self.managed_resolved_link.clone(); let cancellation_token = self.cancellation_token.clone(); let handle = tokio::spawn(async move { - if !wait_for_linux_dns_listener_targets(&listen, &cancellation_token) - .await - { - return; - } - let h = DnsMessageExchanger { resolver }; - let r = chimera_dns::get_dns_listener(listen_for_server, h, &cwd).await; + let r = chimera_dns::get_dns_listener(listen, h, &cwd).await; if let Some(r) = r { - maybe_take_over_linux_stub_resolver( - &listen, - &managed_resolv_conf_backup, - &managed_resolved_link, - manage_system_resolver, - ) - .await; tokio::select! { res = r => { match res { @@ -456,23 +96,16 @@ impl Runner for DnsRunner { error!("dns listener error: {}", err); } } - maybe_restore_linux_stub_resolver( - &managed_resolv_conf_backup, - &managed_resolved_link, - ) - .await; }, _ = cancellation_token.cancelled() => { info!("dns listener is closed"); - maybe_restore_linux_stub_resolver( - &managed_resolv_conf_backup, - &managed_resolved_link, - ) - .await; }, } } else { - info!("dns listener: no listen addresses configured, skipping"); + error!( + "dns listener: no listener started; no addresses were configured or all \ + configured addresses failed to bind" + ); } }); @@ -488,13 +121,12 @@ impl Runner for DnsRunner { fn join(&self) -> futures::future::BoxFuture<'_, Result<(), crate::Error>> { let handle = self.task.lock().unwrap().take(); async move { - match handle { - Some(handle) => handle.await.map_err(|err| { + if let Some(handle) = handle { + handle.await.map_err(|err| { crate::Error::Operation(format!( "dns listener join error: {err}" )) - })?, - None => {} + })?; } Ok(()) diff --git a/clash-lib/src/app/net/mod.rs b/clash-lib/src/app/net/mod.rs index b0e25e34..190830e4 100644 --- a/clash-lib/src/app/net/mod.rs +++ b/clash-lib/src/app/net/mod.rs @@ -211,10 +211,12 @@ pub fn resolve_outbound_interface( Interface::Name(name) => get_interface_by_name(name), }); - if interface.is_some() && configured.is_none() { + if let Some(interface) = interface + && configured.is_none() + { warn!( "configured outbound interface {} not found, falling back to auto-detect", - interface.expect("checked is_some") + interface ); } diff --git a/clash-lib/src/app/router/mod.rs b/clash-lib/src/app/router/mod.rs index af6ac407..7b18c137 100644 --- a/clash-lib/src/app/router/mod.rs +++ b/clash-lib/src/app/router/mod.rs @@ -111,15 +111,13 @@ impl Router { if sess.destination.is_domain() && r.should_resolve_ip() && !sess_resolved - { - if let Ok(Some(ip)) = self + && let Ok(Some(ip)) = self .dns_resolver .resolve(sess.destination.domain().unwrap(), false) .await - { - sess.resolved_ip = Some(ip); - sess_resolved = true; - } + { + sess.resolved_ip = Some(ip); + sess_resolved = true; } if let Some(ip) = sess.resolved_ip.or(sess.destination.ip()) { diff --git a/clash-lib/src/common/trie.rs b/clash-lib/src/common/trie.rs index 750f3edf..b032ed0a 100644 --- a/clash-lib/src/common/trie.rs +++ b/clash-lib/src/common/trie.rs @@ -44,26 +44,8 @@ impl Node { self.children.insert(key.to_string(), child); } - fn traverse(&self, prefix: &mut Vec, f: &mut F) -> bool - where - F: FnMut(&str, &T) -> bool, - { - if let Some(data) = self.data.as_deref() { - let domain = prefix.iter().rev().cloned().collect::>().join("."); - if !f(&domain, data) { - return false; - } - } - - for (label, child) in &self.children { - prefix.push(label.clone()); - if !child.traverse(prefix, f) { - return false; - } - prefix.pop(); - } - - true + pub fn get_children(&self) -> &HashMap> { + &self.children } } @@ -125,8 +107,49 @@ impl StringTrie { where F: FnMut(&str, &T) -> bool, { - let mut prefix = Vec::new(); - self.root.traverse(&mut prefix, &mut f); + for (key, child) in self.root.get_children() { + Self::traverse_inner(&[key], child, &mut f); + if let Some(data) = child.get_data() + && !f(key, data) + { + return; + } + } + } + + fn traverse_inner<'a, F>( + keys: &'a [&String], + node: &'a Node, + f: &mut F, + ) -> bool + where + F: FnMut(&str, &T) -> bool, + { + for (key, child) in node.get_children() { + let keys = [&[key], keys].concat(); + if let Some(data) = child.get_data() { + let domain = keys + .iter() + .map(|key| key.as_str()) + .collect::>() + .join(DOMAIN_STEP); + let domain = if domain.starts_with(DOMAIN_STEP) { + COMPLEX_WILDCARD.to_string() + &domain + } else { + domain + }; + + if !f(&domain, data) { + return false; + } + } + + if !Self::traverse_inner(&keys, child, f) { + return false; + } + } + + true } fn insert_inner(&mut self, parts: &[&str], data: Arc) { diff --git a/clash-lib/src/config/def.rs b/clash-lib/src/config/def.rs index ffdc7217..fb0d6c2f 100644 --- a/clash-lib/src/config/def.rs +++ b/clash-lib/src/config/def.rs @@ -23,7 +23,8 @@ fn default_tun_device_id() -> String { } fn default_tun_address() -> String { - // reference: mihomo + // Keep the TUN link subnet separate from the default 198.19.0.0/16 + // fake-IP pool. Fake-IP traffic is routed to TUN explicitly. "198.18.0.1/30".to_string() } @@ -106,6 +107,7 @@ pub struct Config { pub rule: Option>, /// 6. Log level + /// /// Either `debug`, `info`, `warning`, `error` or `off` pub log_level: LogLevel, /// external controller address @@ -187,6 +189,7 @@ pub struct Config { /// experimental settings, if any pub experimental: Option, /// 15. Clash router working mode + /// /// Either `rule`, `global` or `direct` #[serde(default)] pub mode: RunMode, @@ -407,6 +410,7 @@ pub struct DNS { /// disabled. pub listen: Option, /// 3. When disabled, system DNS config will be used + /// /// All other DNS related options will only be used when this is enabled pub enable: bool, /// Whether to use `Config::hosts` when resolving hostnames diff --git a/clash-lib/src/config/internal/config.rs b/clash-lib/src/config/internal/config.rs index 01194277..2f7ae8d4 100644 --- a/clash-lib/src/config/internal/config.rs +++ b/clash-lib/src/config/internal/config.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashMap, HashSet}, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, str::FromStr, }; @@ -205,19 +205,56 @@ pub struct TunConfig { pub so_mark: Option, pub route_table: u32, pub dns_hijack: bool, + pub dns_hijack_rules: Vec, } -impl TunConfig { - pub fn dedicated_dns_ipv4(&self) -> Option { - let network = u32::from(self.gateway.network()); - let broadcast = u32::from(self.gateway.broadcast()); - let candidate = network.checked_add(2)?; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DnsHijackProtocol { + Udp, + Tcp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DnsHijackAddress { + Any, + Ip(IpAddr), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DnsHijackRule { + pub protocol: DnsHijackProtocol, + pub address: DnsHijackAddress, + pub port: u16, +} - if candidate >= broadcast { - return None; +impl DnsHijackRule { + pub fn matches_udp(&self, destination: SocketAddr) -> bool { + self.protocol == DnsHijackProtocol::Udp + && self.port == destination.port() + && match self.address { + DnsHijackAddress::Any => true, + DnsHijackAddress::Ip(address) => address == destination.ip(), + } + } +} + +impl TunConfig { + pub fn dns_hijack_udp_ports(&self) -> Vec { + if !self.dns_hijack { + return Vec::new(); + } + if self.dns_hijack_rules.is_empty() { + return vec![53]; } - Some(Ipv4Addr::from(candidate)) + let mut ports = Vec::new(); + for rule in &self.dns_hijack_rules { + if rule.protocol == DnsHijackProtocol::Udp && !ports.contains(&rule.port) + { + ports.push(rule.port); + } + } + ports } } @@ -263,97 +300,75 @@ pub struct InlineRuleProvider { #[cfg(test)] mod tests { - use std::net::Ipv4Addr; + use std::net::{IpAddr, SocketAddr}; - use super::TunConfig; - - #[test] - fn dedicated_dns_is_gateway_plus_one_in_24() { - let tun = TunConfig { - gateway: "198.18.0.1/24".parse().unwrap(), - ..Default::default() - }; - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(198, 18, 0, 2))); - } - - #[test] - fn dedicated_dns_in_16_subnet() { - let tun = TunConfig { - gateway: "10.0.0.1/16".parse().unwrap(), - ..Default::default() - }; - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(10, 0, 0, 2))); - } + use super::{DnsHijackAddress, DnsHijackProtocol, DnsHijackRule, TunConfig}; #[test] - fn dedicated_dns_gateway_is_network_plus_one() { - let tun = TunConfig { - gateway: "172.16.0.5/24".parse().unwrap(), - ..Default::default() + fn dns_hijack_rule_matches_udp_destination() { + let any = DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 53, }; - // gateway addr (172.16.0.5) != network+1 (172.16.0.1), but - // dedicated_dns_ipv4 computes from network address: network+2 = 172.16.0.2 - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(172, 16, 0, 2))); - } - - #[test] - fn dedicated_dns_in_30_subnet_room_for_dns() { - let tun = TunConfig { - gateway: "10.0.0.1/30".parse().unwrap(), - ..Default::default() + let specific = DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Ip("1.1.1.1".parse().unwrap()), + port: 53, }; - // /30 subnet: network=10.0.0.0, broadcast=10.0.0.3 - // network+2 = 10.0.0.2 < 10.0.0.3, so Some(10.0.0.2) - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(10, 0, 0, 2))); - } - - #[test] - fn dedicated_dns_in_31_subnet_too_small() { - let tun = TunConfig { - gateway: "10.0.0.1/31".parse().unwrap(), - ..Default::default() + let tcp = DnsHijackRule { + protocol: DnsHijackProtocol::Tcp, + ..any }; - // /31 subnet: network=10.0.0.0, broadcast=10.0.0.1 - // candidate overflow would clamp, so None - assert_eq!(tun.dedicated_dns_ipv4(), None); - } - #[test] - fn dedicated_dns_in_32_subnet_too_small() { - let tun = TunConfig { - gateway: "10.0.0.1/32".parse().unwrap(), - ..Default::default() + assert!(any.matches_udp("8.8.8.8:53".parse().unwrap())); + assert!(!any.matches_udp("8.8.8.8:5353".parse().unwrap())); + assert!(specific.matches_udp("1.1.1.1:53".parse().unwrap())); + assert!(!specific.matches_udp("8.8.8.8:53".parse().unwrap())); + assert!(!tcp.matches_udp("1.1.1.1:53".parse().unwrap())); + + let ipv6 = DnsHijackRule { + address: DnsHijackAddress::Ip(IpAddr::V6( + "2001:4860:4860::8888".parse().unwrap(), + )), + ..any }; - // /32 subnet: network=10.0.0.1, broadcast=10.0.0.1 - // network+2 = 10.0.0.3 > 10.0.0.1, so None - assert_eq!(tun.dedicated_dns_ipv4(), None); + assert!(ipv6.matches_udp(SocketAddr::new( + "2001:4860:4860::8888".parse().unwrap(), + 53 + ))); } #[test] - fn dedicated_dns_overflow_protection() { - // Use network address near u32::MAX to test checked_add safety + fn dns_hijack_udp_ports_follow_protocol_rules() { let tun = TunConfig { - gateway: "255.255.255.0/24".parse().unwrap(), + dns_hijack: true, + dns_hijack_rules: vec![ + DnsHijackRule { + protocol: DnsHijackProtocol::Tcp, + address: DnsHijackAddress::Any, + port: 53, + }, + DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 5353, + }, + DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 5353, + }, + ], ..Default::default() }; - // network = 255.255.255.0, broadcast = 255.255.255.255 - // network+2 = 255.255.255.2 <= broadcast = 255.255.255.255, ok - assert_eq!( - tun.dedicated_dns_ipv4(), - Some(Ipv4Addr::new(255, 255, 255, 2)) - ); - } + assert_eq!(tun.dns_hijack_udp_ports(), vec![5353]); - #[test] - fn dedicated_dns_default_gateway() { - let tun = TunConfig { - gateway: "198.18.0.1/24".parse().unwrap(), + let legacy = TunConfig { + dns_hijack: true, ..Default::default() }; - let dns = tun.dedicated_dns_ipv4().unwrap(); - // DNS must not be the gateway IP itself - assert_ne!(dns, Ipv4Addr::new(198, 18, 0, 1)); - // DNS must not be a public IP - assert!(!dns.is_global()); + assert_eq!(legacy.dns_hijack_udp_ports(), vec![53]); + assert!(TunConfig::default().dns_hijack_udp_ports().is_empty()); } } diff --git a/clash-lib/src/config/internal/convert/mod.rs b/clash-lib/src/config/internal/convert/mod.rs index b2e15504..15b3dcdf 100644 --- a/clash-lib/src/config/internal/convert/mod.rs +++ b/clash-lib/src/config/internal/convert/mod.rs @@ -52,6 +52,9 @@ pub(super) fn convert(mut c: def::Config) -> Result {} } } + let dns: crate::app::dns::Config = (&c).try_into()?; + let mut tun = tun::convert(c.tun.take())?; + configure_fake_ip_route(&dns, &mut tun)?; config::Config { proxies: c.proxy.take().unwrap_or_default().into_iter().try_fold( @@ -113,9 +116,8 @@ pub(super) fn convert(mut c: def::Config) -> Result, _>>()?, general: general::convert(&c)?, - // relate to dns::Config - dns: (&c).try_into()?, - tun: tun::convert(c.tun.take())?, + dns, + tun, experimental: c.experimental.take(), profile: Profile { store_selected: c.profile.store_selected, @@ -125,9 +127,44 @@ pub(super) fn convert(mut c: def::Config) -> Result Result<(), Error> { + if !tun.enable || !dns.enable || dns.enhance_mode != def::DNSMode::FakeIp { + return Ok(()); + } + + let fake_ip_range = match dns.fake_ip_range { + ipnet::IpNet::V4(range) => range, + ipnet::IpNet::V6(_) => { + return Err(Error::InvalidConfig( + "fake-ip-range must be an IPv4 subnet".to_string(), + )); + } + }; + + let tun_network = tun.gateway.trunc(); + if tun_network.contains(&fake_ip_range.network()) + || fake_ip_range.contains(&tun_network.network()) + { + return Err(Error::InvalidConfig(format!( + "tun gateway subnet `{tun_network}` overlaps fake-ip-range \ + `{fake_ip_range}`; use separate subnets" + ))); + } + + let fake_ip_route = ipnet::IpNet::V4(fake_ip_range.trunc()); + if !tun.route_all && !tun.routes.contains(&fake_ip_route) { + tun.routes.push(fake_ip_route); + } + + Ok(()) +} + #[cfg(test)] mod tests { - use crate::config::def; + use crate::{Error, config::def}; use super::convert; @@ -205,6 +242,51 @@ tun: assert!(converted.tun.route_all); } + #[test] + fn fake_ip_mode_adds_route_for_separate_default_pool() { + let mut cfg = parse_config( + r#" +tun: + enable: true +"#, + ); + cfg.dns.enable = true; + cfg.dns.enhanced_mode = def::DNSMode::FakeIp; + cfg.dns.nameserver = vec!["1.1.1.1".to_string()]; + + let converted = convert(cfg).expect("internal convert should succeed"); + assert_eq!(converted.tun.gateway.to_string(), "198.18.0.1/30"); + assert!( + converted + .tun + .routes + .contains(&"198.19.0.0/16".parse().unwrap()) + ); + } + + #[test] + fn reject_overlapping_tun_and_fake_ip_subnets() { + let mut cfg = parse_config( + r#" +tun: + enable: true + gateway: 198.18.0.1/30 +"#, + ); + cfg.dns.enable = true; + cfg.dns.enhanced_mode = def::DNSMode::FakeIp; + cfg.dns.fake_ip_range = "198.18.0.0/16".to_string(); + cfg.dns.nameserver = vec!["1.1.1.1".to_string()]; + + match convert(cfg) { + Err(Error::InvalidConfig(message)) => { + assert!(message.contains("overlaps fake-ip-range")); + } + Err(other) => panic!("unexpected error: {other}"), + Ok(_) => panic!("overlapping subnets must be rejected"), + } + } + #[test] fn parse_relay_group_with_proxies() { let cfg = parse_config( diff --git a/clash-lib/src/config/internal/convert/tun.rs b/clash-lib/src/config/internal/convert/tun.rs index 73697b68..456f8ed6 100644 --- a/clash-lib/src/config/internal/convert/tun.rs +++ b/clash-lib/src/config/internal/convert/tun.rs @@ -1,8 +1,88 @@ use crate::{ Error, - config::{def, internal::config}, + config::{ + def, + internal::config::{ + self, DnsHijackAddress, DnsHijackProtocol, DnsHijackRule, + }, + }, }; +fn parse_dns_hijack_rule(value: &str) -> Result { + let (protocol, address) = match value.split_once("://") { + Some(("udp", address)) => (DnsHijackProtocol::Udp, address), + Some(("tcp", address)) => (DnsHijackProtocol::Tcp, address), + Some((protocol, _)) => { + return Err(Error::InvalidConfig(format!( + "parse tun dns-hijack: unsupported protocol {protocol}" + ))); + } + None => (DnsHijackProtocol::Udp, value), + }; + + let (address, port) = address.rsplit_once(':').ok_or_else(|| { + Error::InvalidConfig(format!( + "parse tun dns-hijack: missing port in {value}" + )) + })?; + let port = port.parse::().map_err(|e| { + Error::InvalidConfig(format!("parse tun dns-hijack port in {value}: {e}")) + })?; + if port == 0 { + return Err(Error::InvalidConfig(format!( + "parse tun dns-hijack: port must not be zero in {value}" + ))); + } + + let address = if address == "any" { + DnsHijackAddress::Any + } else { + if address.contains(':') + && !(address.starts_with('[') && address.ends_with(']')) + { + return Err(Error::InvalidConfig(format!( + "parse tun dns-hijack: IPv6 address must be bracketed in {value}" + ))); + } + let address = address + .strip_prefix('[') + .and_then(|address| address.strip_suffix(']')) + .unwrap_or(address); + DnsHijackAddress::Ip(address.parse().map_err(|e| { + Error::InvalidConfig(format!( + "parse tun dns-hijack address in {value}: {e}" + )) + })?) + }; + + Ok(DnsHijackRule { + protocol, + address, + port, + }) +} + +fn parse_dns_hijack( + value: def::DnsHijack, +) -> Result<(bool, Vec), crate::Error> { + let (enabled, values) = match value { + def::DnsHijack::Switch(false) => return Ok((false, Vec::new())), + def::DnsHijack::Switch(true) => { + (true, vec!["any:53".to_string(), "tcp://any:53".to_string()]) + } + def::DnsHijack::List(values) => (true, values), + }; + + let mut rules = Vec::with_capacity(values.len()); + for value in values { + let rule = parse_dns_hijack_rule(&value)?; + if !rules.contains(&rule) { + rules.push(rule); + } + } + Ok((enabled, rules)) +} + pub(super) fn convert( before: Option, ) -> Result { @@ -24,6 +104,7 @@ pub(super) fn convert( match before { Some(t) => { + let (dns_hijack, dns_hijack_rules) = parse_dns_hijack(t.dns_hijack)?; let mut route_exclude_address = parse_routes(t.route_exclude_address, "route-exclude-address")?; @@ -79,10 +160,8 @@ pub(super) fn convert( mtu: t.mtu, so_mark: t.so_mark, route_table: t.route_table, - dns_hijack: match t.dns_hijack { - def::DnsHijack::Switch(v) => v, - def::DnsHijack::List(_) => true, - }, + dns_hijack, + dns_hijack_rules, }) } None => Ok(config::TunConfig::default()), @@ -91,7 +170,10 @@ pub(super) fn convert( #[cfg(test)] mod tests { - use crate::config::def; + use crate::config::{ + def, + internal::config::{DnsHijackAddress, DnsHijackProtocol, DnsHijackRule}, + }; use super::convert; @@ -115,7 +197,7 @@ mod tests { assert_eq!(converted.device_id, "utun1989"); assert_eq!(converted.route_table, 2468); - assert_eq!(converted.gateway.to_string(), "198.18.0.1/24"); + assert_eq!(converted.gateway.to_string(), "198.18.0.1/30"); assert!(!converted.dns_hijack); } @@ -130,6 +212,84 @@ dns-hijack: ); let converted = convert(Some(tun)).expect("tun convert should succeed"); assert!(converted.dns_hijack); + assert_eq!( + converted.dns_hijack_rules, + vec![DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 53, + }] + ); + } + + #[test] + fn expand_dns_hijack_true_to_udp_and_tcp() { + let converted = convert(Some(parse_tun("enable: true\ndns-hijack: true"))) + .expect("valid rule"); + + assert_eq!(converted.dns_hijack_rules.len(), 2); + assert_eq!( + converted.dns_hijack_rules[0].protocol, + DnsHijackProtocol::Udp + ); + assert_eq!( + converted.dns_hijack_rules[1].protocol, + DnsHijackProtocol::Tcp + ); + } + + #[test] + fn preserve_empty_dns_hijack_list_as_enabled() { + let converted = convert(Some(parse_tun("enable: true\ndns-hijack: []"))) + .expect("empty list remains compatible"); + + assert!(converted.dns_hijack); + assert!(converted.dns_hijack_rules.is_empty()); + } + + #[test] + fn parse_dns_hijack_addresses_and_remove_duplicates() { + let tun = parse_tun( + r#" +enable: true +dns-hijack: + - 1.1.1.1:53 + - tcp://[::1]:53 + - 1.1.1.1:53 +"#, + ); + let converted = convert(Some(tun)).expect("valid rules"); + + assert_eq!(converted.dns_hijack_rules.len(), 2); + assert_eq!( + converted.dns_hijack_rules[0].address, + DnsHijackAddress::Ip("1.1.1.1".parse().unwrap()) + ); + assert_eq!( + converted.dns_hijack_rules[1].address, + DnsHijackAddress::Ip("::1".parse().unwrap()) + ); + } + + #[test] + fn reject_invalid_dns_hijack_rules() { + for rule in [ + "quic://any:53", + "any", + "any:0", + "any:65536", + "not-an-ip:53", + "::1:53", + ] { + let tun = parse_tun(&format!("enable: true\ndns-hijack: [\"{rule}\"]")); + match convert(Some(tun)) { + Err(crate::Error::InvalidConfig(message)) => { + assert!(message.contains("parse tun dns-hijack")) + } + Err(other) => panic!("unexpected error for {rule}: {other}"), + Ok(_) => panic!("invalid rule should fail: {rule}"), + } + } } #[test] diff --git a/clash-lib/src/config/internal/proxy.rs b/clash-lib/src/config/internal/proxy.rs index 9fcc2496..648fc472 100644 --- a/clash-lib/src/config/internal/proxy.rs +++ b/clash-lib/src/config/internal/proxy.rs @@ -26,6 +26,7 @@ impl OutboundProxy { #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] pub enum OutboundProxyProtocol { #[serde(rename = "direct")] Direct(OutboundDirect), diff --git a/clash-lib/src/lib.rs b/clash-lib/src/lib.rs index 180ccb91..2c78bc5a 100644 --- a/clash-lib/src/lib.rs +++ b/clash-lib/src/lib.rs @@ -229,6 +229,24 @@ pub fn setup_default_crypto_provider() { }); } +async fn wait_for_shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] + { + let mut terminate = tokio::signal::unix::signal( + tokio::signal::unix::SignalKind::terminate(), + )?; + tokio::select! { + result = tokio::signal::ctrl_c() => result, + _ = terminate.recv() => Ok(()), + } + } + + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await + } +} + pub async fn start( config: InternalConfig, cwd: String, @@ -394,7 +412,7 @@ pub async fn start( }); tokio::select! { - result = tokio::signal::ctrl_c() => { + result = wait_for_shutdown_signal() => { result.map_err(Error::Io)?; shutdown_token.cancel(); } @@ -461,15 +479,6 @@ impl RuntimeComponents { } } -#[cfg(feature = "tun")] -fn dns_listener_is_empty(listen: &DNSListenAddr) -> bool { - listen.udp.is_none() - && listen.tcp.is_none() - && listen.doh.is_none() - && listen.dot.is_none() - && listen.doh3.is_none() -} - async fn create_components( cwd: PathBuf, config: InternalConfig, @@ -548,32 +557,8 @@ async fn create_components( debug!("initializing dns resolver"); // Clone the dns.listen for the DNS Server later before we consume the config // TODO: we should separate the DNS resolver and DNS server config here - #[allow(unused_mut)] - let mut dns_listen = config.dns.listen.clone(); + let dns_listen = config.dns.listen.clone(); let dns_enable = config.dns.enable; - #[cfg(feature = "tun")] - let auto_manage_linux_dns = cfg!(target_os = "linux") - && config.tun.enable - && config.tun.dns_hijack - && dns_enable - && dns_listener_is_empty(&dns_listen); - - #[cfg(feature = "tun")] - if auto_manage_linux_dns { - let dedicated_dns_ip = config.tun.dedicated_dns_ipv4().ok_or_else(|| { - Error::InvalidConfig( - "tun dns-hijack requires a subnet with room for a dedicated DNS address" - .to_string(), - ) - })?; - let tun_dns_addr = std::net::SocketAddr::from((dedicated_dns_ip, 53)); - dns_listen.udp = Some(tun_dns_addr); - dns_listen.tcp = Some(tun_dns_addr); - info!( - "auto-enabling linux tun DNS listener on {} for systemd-resolved per-link takeover", - tun_dns_addr - ); - } // Extract the country MMDB file/url config early so they can be consumed // here, while the actual MMDB loading happens after OutboundManager (like @@ -775,10 +760,6 @@ async fn create_components( dns_listen.clone(), dns_resolver.clone(), &cwd, - #[cfg(feature = "tun")] - auto_manage_linux_dns, - #[cfg(not(feature = "tun"))] - false, Some(cancellation_token.child_token()), )); diff --git a/clash-lib/src/proxy/group/relay/mod.rs b/clash-lib/src/proxy/group/relay/mod.rs index 6ef53606..4af711bf 100644 --- a/clash-lib/src/proxy/group/relay/mod.rs +++ b/clash-lib/src/proxy/group/relay/mod.rs @@ -46,6 +46,7 @@ impl std::fmt::Debug for Handler { } impl Handler { + #[allow(clippy::new_ret_no_self)] pub fn new( opts: HandlerOptions, providers: Vec, diff --git a/clash-lib/src/proxy/hysteria2/congestion.rs b/clash-lib/src/proxy/hysteria2/congestion.rs index a006ef49..ef1e3a96 100644 --- a/clash-lib/src/proxy/hysteria2/congestion.rs +++ b/clash-lib/src/proxy/hysteria2/congestion.rs @@ -133,11 +133,7 @@ impl Controller for Brutal { None => max_budget, }; - self.budget_at_last_sent = if bytes > budget as u64 { - 0 - } else { - budget as u64 - bytes - }; + self.budget_at_last_sent = (budget as u64).saturating_sub(bytes); self.last_send_time = Some(now); } diff --git a/clash-lib/src/proxy/tun/datagram.rs b/clash-lib/src/proxy/tun/datagram.rs index 09026c3c..a547c4f4 100644 --- a/clash-lib/src/proxy/tun/datagram.rs +++ b/clash-lib/src/proxy/tun/datagram.rs @@ -5,6 +5,7 @@ use crate::{ net::DEFAULT_OUTBOUND_INTERFACE, }, common::errors::new_io_error, + config::internal::config::DnsHijackRule, proxy::datagram::UdpPacket, session::{Network, Session, Type}, }; @@ -32,12 +33,26 @@ enum FlushState { Disconnected, } +fn should_hijack_udp_dns( + enabled: bool, + rules: &[DnsHijackRule], + destination: std::net::SocketAddr, +) -> bool { + enabled + && if rules.is_empty() { + destination.port() == 53 + } else { + rules.iter().any(|rule| rule.matches_udp(destination)) + } +} + pub(crate) async fn handle_inbound_datagram( socket: watfaq_netstack::UdpSocket, dispatcher: Arc, resolver: ThreadSafeDNSResolver, so_mark: Option, dns_hijack: bool, + dns_hijack_rules: Vec, ) { // tun i/o // lr: app packets went into tun will be accessed from lr @@ -123,7 +138,7 @@ pub(crate) async fn handle_inbound_datagram( trace!("tun -> dispatcher: {:?}", pkt); - if dns_hijack && pkt.dst_addr.port() == 53 { + if should_hijack_udp_dns(dns_hijack, &dns_hijack_rules, remote_addr) { trace!("got dns packet: {:?}, returning from Clash DNS server", pkt); match hickory_proto::op::Message::from_vec(&pkt.data) { @@ -372,8 +387,14 @@ impl Sink for TunDatagram { #[cfg(test)] mod tests { - use super::{TunDatagram, is_noise_datagram}; - use crate::{proxy::datagram::UdpPacket, session::SocksAddr}; + use super::{TunDatagram, is_noise_datagram, should_hijack_udp_dns}; + use crate::{ + config::internal::config::{ + DnsHijackAddress, DnsHijackProtocol, DnsHijackRule, + }, + proxy::datagram::UdpPacket, + session::SocksAddr, + }; use futures::{Sink, task::noop_waker_ref}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, @@ -392,6 +413,31 @@ mod tests { ) } + #[test] + fn udp_dns_hijack_uses_rules_and_preserves_empty_list_compatibility() { + let destination = "1.1.1.1:53".parse().unwrap(); + let tcp_rule = DnsHijackRule { + protocol: DnsHijackProtocol::Tcp, + address: DnsHijackAddress::Any, + port: 53, + }; + let udp_rule = DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Ip("1.1.1.1".parse().unwrap()), + port: 53, + }; + + assert!(!should_hijack_udp_dns(false, &[], destination)); + assert!(should_hijack_udp_dns(true, &[], destination)); + assert!(!should_hijack_udp_dns(true, &[tcp_rule], destination)); + assert!(should_hijack_udp_dns(true, &[udp_rule], destination)); + assert!(!should_hijack_udp_dns( + true, + &[udp_rule], + "8.8.8.8:53".parse().unwrap() + )); + } + #[tokio::test(flavor = "current_thread")] async fn tun_datagram_flush_waits_for_capacity_instead_of_failing() { let (tx, mut rx) = tokio::sync::mpsc::channel(1); diff --git a/clash-lib/src/proxy/tun/routes/linux.rs b/clash-lib/src/proxy/tun/routes/linux.rs index ee681a75..709ed852 100644 --- a/clash-lib/src/proxy/tun/routes/linux.rs +++ b/clash-lib/src/proxy/tun/routes/linux.rs @@ -1,6 +1,6 @@ -use std::net::{IpAddr, Ipv4Addr}; +use std::net::IpAddr; -use ipnet::{IpNet, Ipv4Net}; +use ipnet::IpNet; use tracing::warn; use crate::{ @@ -8,11 +8,15 @@ use crate::{ config::internal::config::TunConfig, }; -const FWMARK_MAIN_RULE_PREF: &str = "100"; -const DNS_HIJACK_RULE_PREF: &str = "101"; +const FWMARK_MAIN_RULE_PREF: &str = "88"; +// DNS must be selected before the main-table lookup at preference 90. +// Chimera's SO_MARK-to-main rule at preference 88 prevents its own upstream +// DNS traffic from looping back into the TUN. +const DNS_HIJACK_RULE_PREF: &str = "89"; const ROUTE_ALL_RULE_PREF: &str = "102"; -const MAIN_SUPPRESS_RULE_PREF: &str = "103"; -const NIXOS_SOURCE_MAIN_RULE_PREF: &str = "90"; +// Resolve connected, LAN, VPN, and other specific main-table routes before the +// catch-all TUN rule, while suppressing only the physical default route. +const MAIN_SUPPRESS_RULE_PREF: &str = "90"; fn is_missing_ip_state(stderr: &str) -> bool { matches!( @@ -106,38 +110,6 @@ fn add_excluded_route(table: &str, dest: &IpNet) -> std::io::Result<()> { } } -fn is_nixos() -> bool { - std::path::Path::new("/etc/NIXOS").exists() - || std::path::Path::new("/run/current-system/sw/bin").exists() -} - -fn main_route_source_v4() -> std::io::Result> { - let output = std::process::Command::new("ip") - .args(["-4", "route", "get", "1.1.1.1"]) - .output()?; - warn!("executing: ip -4 route get 1.1.1.1"); - if !output.status.success() { - return Err(new_io_error(format!( - "query default IPv4 source failed: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let Some(line) = stdout.lines().next() else { - return Ok(None); - }; - - let mut parts = line.split_whitespace(); - while let Some(part) = parts.next() { - if part == "src" { - return Ok(parts.next().and_then(|src| src.parse().ok())); - } - } - - Ok(None) -} - pub fn delete_interface(name: &str) -> std::io::Result<()> { let cmd_str = format!("ip link del dev {name}"); let args = ["link", "del", "dev", name]; @@ -148,32 +120,6 @@ pub fn delete_interface(name: &str) -> std::io::Result<()> { Ok(()) } -pub fn ensure_interface_address(name: &str, addr: Ipv4Net) -> std::io::Result<()> { - let cidr = addr.to_string(); - let cmd = std::process::Command::new("ip") - .args(["addr", "add", &cidr, "dev", name]) - .output()?; - warn!("executing: ip addr add {} dev {}", cidr, name); - - if cmd.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&cmd.stderr); - if stderr.contains("File exists") { - warn!( - "address {} already configured on {}, continuing", - cidr, name - ); - return Ok(()); - } - - Err(new_io_error(format!( - "ip addr add {} dev {} failed: {}", - cidr, name, stderr - ))) -} - fn run_ip_cmd_single( cmd_str: &str, args: &[&str], @@ -227,10 +173,9 @@ fn delete_ip_cmd_all(args: &[&str], enable_v6: bool) -> std::io::Result<()> { /// three rules are added: /// # ip route add default dev wg0 table 2468 -/// # ip rule add pref 90 from $OUTBOUND_IPV4 table main (NixOS only) -/// # ip rule add pref 100 fwmark 1234 table main +/// # ip rule add pref 88 fwmark 1234 table main +/// # ip rule add pref 90 table main suppress_prefixlength 0 /// # ip rule add pref 102 not fwmark 1234 table 2468 -/// # ip rule add pref 103 table main suppress_prefixlength 0 /// for ipv6 /// # ip -6 ... pub fn setup_policy_routing( @@ -250,29 +195,6 @@ pub fn setup_policy_routing( add_excluded_route(&table, route)?; } - if is_nixos() { - match main_route_source_v4()? { - Some(addr_v4) => { - run_ip_cmd( - &[ - "rule", - "add", - "pref", - NIXOS_SOURCE_MAIN_RULE_PREF, - "from", - &format!("{addr_v4}/32"), - "table", - "main", - ], - false, - )?; - } - None => { - warn!("NixOS source-main rule skipped: no default IPv4 source found") - } - } - } - if let Some(so_mark) = tun_cfg.so_mark { run_ip_cmd( &[ @@ -318,14 +240,31 @@ pub fn setup_policy_routing( enable_v6, )?; + for port in tun_cfg.dns_hijack_udp_ports() { + run_ip_cmd( + &[ + "rule", + "add", + "pref", + DNS_HIJACK_RULE_PREF, + "ipproto", + "udp", + "dport", + &port.to_string(), + "table", + &table, + ], + enable_v6, + )?; + } + Ok(()) } /// policy rules to clean up: -/// # ip rule del pref 90 from $OUTBOUND_IPV4 table main (NixOS only) -/// # ip rule del pref 100 fwmark $SO_MARK table main +/// # ip rule del pref 88 fwmark $SO_MARK table main +/// # ip rule del pref 90 table main suppress_prefixlength 0 /// # ip rule del pref 102 not fwmark $SO_MARK table $TABLE -/// # ip rule del pref 103 table main suppress_prefixlength 0 /// for v6 /// # ip -6 ... pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { @@ -345,19 +284,8 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { )?; } - if is_nixos() { - delete_ip_cmd_all( - &[ - "rule", - "del", - "pref", - NIXOS_SOURCE_MAIN_RULE_PREF, - "table", - "main", - ], - false, - )?; - } + // Also removes the broad source-address bypass emitted by older builds. + delete_ip_cmd_all(&["rule", "del", "pref", "90", "table", "main"], false)?; if let Some(so_mark) = tun_cfg.so_mark { delete_ip_cmd_all( @@ -432,33 +360,17 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { enable_v6, )?; - if let Some(so_mark) = tun_cfg.so_mark { + for port in tun_cfg.dns_hijack_udp_ports() { delete_ip_cmd_all( &[ "rule", "del", "pref", DNS_HIJACK_RULE_PREF, - "not", - "fwmark", - &so_mark.to_string(), + "ipproto", + "udp", "dport", - "53", - "table", - &table, - ], - enable_v6, - )?; - - delete_ip_cmd_all( - &[ - "rule", - "del", - "not", - "fwmark", - &so_mark.to_string(), - "dport", - "53", + &port.to_string(), "table", &table, ], @@ -466,6 +378,7 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { )?; } + // Remove rules left by older builds that did not include ipproto. delete_ip_cmd_all( &[ "rule", @@ -479,6 +392,14 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { ], enable_v6, )?; + // Remove the rule emitted by transparent-dns step 1. + delete_ip_cmd_all( + &[ + "rule", "del", "pref", "101", "ipproto", "udp", "dport", "53", "table", + &table, + ], + enable_v6, + )?; delete_ip_cmd_all(&["rule", "del", "dport", "53", "table", &table], enable_v6)?; Ok(()) diff --git a/clash-lib/src/proxy/tun/routes/mod.rs b/clash-lib/src/proxy/tun/routes/mod.rs index df57e9be..4d8de6b8 100644 --- a/clash-lib/src/proxy/tun/routes/mod.rs +++ b/clash-lib/src/proxy/tun/routes/mod.rs @@ -17,7 +17,7 @@ mod linux; #[cfg(target_os = "linux")] use linux::add_route; #[cfg(target_os = "linux")] -pub use linux::{delete_interface, ensure_interface_address, maybe_routes_clean_up}; +pub use linux::{delete_interface, maybe_routes_clean_up}; #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] mod other; diff --git a/clash-lib/src/proxy/tun/runner.rs b/clash-lib/src/proxy/tun/runner.rs index 30c8a3d8..9d936e3d 100644 --- a/clash-lib/src/proxy/tun/runner.rs +++ b/clash-lib/src/proxy/tun/runner.rs @@ -269,24 +269,6 @@ impl TunRunner { info!("reconciling routes for existing tun {}", &tun_name); } - #[cfg(target_os = "linux")] - if cfg.dns_hijack - && let Some(dedicated_dns_ip) = cfg.dedicated_dns_ipv4() - { - routes::ensure_interface_address( - &tun_name, - ipnet::Ipv4Net::new( - dedicated_dns_ip, - cfg.gateway.prefix_len(), - ) - .map_err(|err| { - Error::Operation(format!( - "failed to build dedicated tun dns cidr: {err}" - )) - })?, - )?; - } - maybe_add_routes(cfg, &tun_name)?; dev @@ -316,6 +298,7 @@ impl Runner for TunRunner { let dispatcher = self.dispatcher.clone(); let resolver = self.resolver.clone(); let dns_hijack = self.cfg.dns_hijack; + let dns_hijack_rules = self.cfg.dns_hijack_rules.clone(); let cancellation_token = self.cancellation_token.clone(); let handle = tokio::spawn(async move { @@ -426,6 +409,7 @@ impl Runner for TunRunner { resolver.clone(), so_mark, dns_hijack, + dns_hijack_rules.clone(), ) .await; Err(Error::Operation("tun stopped unexpectedly 3".to_string())) diff --git a/clash-lib/tests/api_reload_tests.rs b/clash-lib/tests/api_reload_tests.rs index f0da3c38..928c775f 100644 --- a/clash-lib/tests/api_reload_tests.rs +++ b/clash-lib/tests/api_reload_tests.rs @@ -58,6 +58,7 @@ fn write_config(path: &PathBuf, api_port: u16, socks_port: u16, mode: &str) { "ipv6: false\n\ log_level: info\n\ mode: {mode}\n\ +mmdb: null\n\ external-controller: 127.0.0.1:{api_port}\n\ socks-port: {socks_port}\n\ dns:\n\ diff --git a/clash-lib/tests/api_tests.rs b/clash-lib/tests/api_tests.rs index f0470a74..1a16ba1b 100644 --- a/clash-lib/tests/api_tests.rs +++ b/clash-lib/tests/api_tests.rs @@ -2,10 +2,48 @@ use crate::common::{ClashInstance, send_http_request}; use bytes::{Buf, Bytes}; use clash_lib::{Config, Options}; use http_body_util::BodyExt; -use std::{path::PathBuf, time::Duration}; +use std::{net::TcpListener, path::PathBuf, time::Duration}; mod common; +fn available_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("failed to reserve test port") + .local_addr() + .expect("failed to inspect test port") + .port() +} + +fn isolated_config(api_port: u16) -> (PathBuf, u16) { + let socks_port = available_port(); + let path = std::env::temp_dir().join(format!( + "chimera-api-test-{api_port}-{}.yaml", + std::process::id() + )); + std::fs::write( + &path, + format!( + "allow-lan: true\n\ +bind-address: 0.0.0.0\n\ +socks-port: {socks_port}\n\ +mode: direct\n\ +log-level: info\n\ +mmdb: null\n\ +external-controller: 127.0.0.1:{api_port}\n\ +secret: clash-rs\n\ +tun:\n\ + enable: false\n\ +proxies:\n\ + - {{name: DIRECT_alias, type: direct}}\n\ + - {{name: REJECT_alias, type: reject}}\n\ +rules:\n\ + - MATCH,DIRECT\n" + ), + ) + .expect("failed to write isolated test configuration"); + (path, socks_port) +} + async fn get_allow_lan(port: u16) -> bool { let url = format!("http://127.0.0.1:{}/configs", port); let req = hyper::Request::builder() @@ -45,42 +83,48 @@ async fn test_config_reload_via_payload() { config_path.to_string_lossy() ); + let api_port = available_port(); + let reload_api_port = available_port(); + let (isolated_config, socks_port) = isolated_config(api_port); let _clash = ClashInstance::start( Options { - config: Config::File(config_path.to_string_lossy().to_string()), + config: Config::File(isolated_config.to_string_lossy().to_string()), cwd: Some(wd.to_string_lossy().to_string()), rt: None, log_file: None, - config_path: Some(config_path.to_string_lossy().to_string()), + config_path: Some(isolated_config.to_string_lossy().to_string()), }, - vec![9090, 8888, 8889, 8899, 53553, 53554, 53555], + vec![api_port, reload_api_port, socks_port], ) .expect("Failed to start clash"); assert!( - get_allow_lan(9090).await, + get_allow_lan(api_port).await, "expected allow-lan=true before reload" ); - let new_payload = r#" + let new_payload = format!( + r#" socks-port: 7892 bind-address: 127.0.0.1 allow-lan: false mode: direct log-level: info -external-controller: :9091 +mmdb: null +external-controller: 127.0.0.1:{reload_api_port} secret: clash-rs tun: enable: false proxies: - - {name: DIRECT_alias, type: direct} - - {name: REJECT_alias, type: reject} -"#; + - {{name: DIRECT_alias, type: direct}} + - {{name: REJECT_alias, type: reject}} +"# + ); let body = serde_json::json!({ "payload": new_payload }).to_string(); - let configs_url = "http://127.0.0.1:9090/configs"; + let configs_url = format!("http://127.0.0.1:{api_port}/configs"); let req = hyper::Request::builder() - .uri(configs_url) + .uri(&configs_url) .header(hyper::header::AUTHORIZATION, "Bearer clash-rs") .header(hyper::header::CONTENT_TYPE, "application/json") .method(http::method::Method::PUT) @@ -99,7 +143,7 @@ proxies: tokio::time::sleep(Duration::from_millis(500)).await; assert!( - !get_allow_lan(9091).await, + !get_allow_lan(reload_api_port).await, "expected allow-lan=false after reload" ); } @@ -116,26 +160,28 @@ async fn test_get_set_allow_lan() { config_path.to_string_lossy() ); + let api_port = available_port(); + let (isolated_config, socks_port) = isolated_config(api_port); let _clash = ClashInstance::start( Options { - config: Config::File(config_path.to_string_lossy().to_string()), + config: Config::File(isolated_config.to_string_lossy().to_string()), cwd: Some(wd.to_string_lossy().to_string()), rt: None, log_file: None, - config_path: Some(config_path.to_string_lossy().to_string()), + config_path: Some(isolated_config.to_string_lossy().to_string()), }, - vec![9090, 8888, 8889, 8899, 53553, 53554, 53555], + vec![api_port, socks_port], ) .expect("Failed to start clash"); assert!( - get_allow_lan(9090).await, + get_allow_lan(api_port).await, "'allow_lan' should be true by config" ); - let configs_url = "http://127.0.0.1:9090/configs"; + let configs_url = format!("http://127.0.0.1:{api_port}/configs"); let req = hyper::Request::builder() - .uri(configs_url) + .uri(&configs_url) .header(hyper::header::AUTHORIZATION, "Bearer clash-rs") .header(hyper::header::CONTENT_TYPE, "application/json") .method(http::method::Method::PATCH) @@ -148,7 +194,7 @@ async fn test_get_set_allow_lan() { assert_eq!(res.status(), http::StatusCode::ACCEPTED); assert!( - !get_allow_lan(9090).await, + !get_allow_lan(api_port).await, "'allow_lan' should be false after update" ); } diff --git a/clash-lib/tests/tun_fake_ip_real_tests.rs b/clash-lib/tests/tun_fake_ip_real_tests.rs index fd8f1d52..8b31fabc 100644 --- a/clash-lib/tests/tun_fake_ip_real_tests.rs +++ b/clash-lib/tests/tun_fake_ip_real_tests.rs @@ -59,9 +59,7 @@ tun: enable: true device-id: "dev://chimera-test-tun" route-all: false - routes: - - 198.18.0.0/16 - gateway: "198.18.0.1/24" + gateway: "198.19.0.1/30" dns-hijack: false dns: diff --git a/clash-netstack/tests/common.rs b/clash-netstack/tests/common.rs index 6211f265..0e14b8bf 100644 --- a/clash-netstack/tests/common.rs +++ b/clash-netstack/tests/common.rs @@ -169,10 +169,10 @@ pub fn build_tcp_ack(seq: u32, ack: u32, window: u16) -> Bytes { buf.put_u16(0x0000); // checksum placeholder buf.put_u16(0x0000); // urgent pointer // checksums - let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..].to_vec()); + let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..]); let chk_pos = tcp_start + 16; buf[chk_pos..chk_pos + 2].copy_from_slice(&tcp_sum.to_be_bytes()); - let ip_sum = ipv4_checksum(&buf[..20].to_vec()); + let ip_sum = ipv4_checksum(&buf[..20]); buf[10..12].copy_from_slice(&ip_sum.to_be_bytes()); buf.freeze() } @@ -203,10 +203,10 @@ pub fn build_tcp_syn_packet_with_port(src_port: u16) -> Bytes { buf.put_u16(0x7210); buf.put_u16(0x0000); // TCP checksum placeholder buf.put_u16(0x0000); // urgent - let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..].to_vec()); + let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..]); let chk_pos = tcp_start + 16; buf[chk_pos..chk_pos + 2].copy_from_slice(&tcp_sum.to_be_bytes()); - let ip_sum = ipv4_checksum(&buf[..20].to_vec()); + let ip_sum = ipv4_checksum(&buf[..20]); buf[10..12].copy_from_slice(&ip_sum.to_be_bytes()); buf.freeze() } diff --git a/clash-netstack/tests/integration_test.rs b/clash-netstack/tests/integration_test.rs index 7c29d559..2131234f 100644 --- a/clash-netstack/tests/integration_test.rs +++ b/clash-netstack/tests/integration_test.rs @@ -283,23 +283,23 @@ async fn test_new_connection_during_active_transfer() { .expect("conn1 stalled for 5 s") .expect("rx1 closed"); - if let Some((seq, payload_len)) = parse_tcp_data(pkt.data()) { - if payload_len > 0 { - let end_seq = seq.wrapping_add(payload_len as u32); - let advance = end_seq.wrapping_sub(cumulative_ack); - if advance > 0 && advance < (1u32 << 31) { - received += advance as usize; - cumulative_ack = end_seq; - } - tun_in - .send(build_tcp_ack(client_seq, cumulative_ack, u16::MAX)) - .unwrap(); - // Signal conn2 client on the first received data segment. - if !signalled { - signalled = true; - if let Some(tx) = ready_tx.take() { - let _ = tx.send(()); - } + if let Some((seq, payload_len)) = parse_tcp_data(pkt.data()) + && payload_len > 0 + { + let end_seq = seq.wrapping_add(payload_len as u32); + let advance = end_seq.wrapping_sub(cumulative_ack); + if advance > 0 && advance < (1u32 << 31) { + received += advance as usize; + cumulative_ack = end_seq; + } + tun_in + .send(build_tcp_ack(client_seq, cumulative_ack, u16::MAX)) + .unwrap(); + // Signal conn2 client on the first received data segment. + if !signalled { + signalled = true; + if let Some(tx) = ready_tx.take() { + let _ = tx.send(()); } } } diff --git a/flake.nix b/flake.nix index 1c61a7f5..010a8c1c 100644 --- a/flake.nix +++ b/flake.nix @@ -6,12 +6,28 @@ inputs.nixpkgs.url = "path:/nix/store/pzxxxg9vvzk63122vj38lcmqg9dl6qxk-nixos-26.05.1947.a0374025a863/nixos"; - outputs = { nixpkgs, ... }: + outputs = { self, nixpkgs, ... }: let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; + chimeraClient = pkgs.callPackage ./nix/package.nix { }; in { + packages.${system} = { + chimera-client = chimeraClient; + default = chimeraClient; + }; + + nixosModules.chimera-client = + { lib, pkgs, ... }: + { + imports = [ ./nix/module.nix ]; + services.chimera-client.package = + lib.mkDefault self.packages.${pkgs.system}.default; + }; + + nixosModules.default = self.nixosModules.chimera-client; + devShells.${system}.default = pkgs.mkShell { nativeBuildInputs = with pkgs; [ cargo diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 00000000..ee8fb013 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,168 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: + +let + cfg = config.services.chimera-client; + executable = lib.getExe cfg.package; + stateDirectory = "/var/lib/private/${cfg.stateDirectory}"; + # %d expands to the service credential directory in systemd command lines. + # Unlike $CREDENTIALS_DIRECTORY it survives escapeSystemdExecArgs unchanged. + credentialConfig = "%d/config.yaml"; + + capabilities = + lib.optional cfg.tun.enable "CAP_NET_ADMIN" + ++ lib.optionals cfg.processInspection [ + "CAP_DAC_READ_SEARCH" + "CAP_SYS_PTRACE" + ]; + + commonArgs = [ + executable + "--directory" + stateDirectory + "--config" + credentialConfig + ]; + + escapeExecArgs = + args: + lib.replaceStrings [ "%%d" ] [ "%d" ] + (utils.escapeSystemdExecArgs args); +in +{ + options.services.chimera-client = { + enable = lib.mkEnableOption "Chimera Client rule-based proxy service"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./package.nix { }; + defaultText = lib.literalExpression "pkgs.callPackage ./nix/package.nix { }"; + description = "Chimera Client package containing the clash-rs executable."; + }; + + configFile = lib.mkOption { + type = lib.types.path; + description = '' + Path to the YAML configuration. The file is loaded with a systemd + credential so secrets do not need to be copied into the Nix store. + ''; + }; + + checkConfig = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Validate the configuration before starting the service."; + }; + + tun.enable = lib.mkEnableOption "permissions required for TUN mode"; + + processInspection = + lib.mkEnableOption "permissions required for process matching rules"; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Additional non-secret command-line arguments."; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Environment variables passed to Chimera Client."; + }; + + stateDirectory = lib.mkOption { + type = lib.types.strMatching "[A-Za-z0-9_.-]+"; + default = "chimera-client"; + description = "Name of the systemd-managed state directory."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = + lib.meta.availableOn pkgs.stdenv.hostPlatform cfg.package; + message = '' + services.chimera-client.package is unavailable on + ${pkgs.stdenv.hostPlatform.system}. + ''; + } + ]; + + systemd.services.chimera-client = { + description = "Chimera Client rule-based proxy service"; + documentation = [ "https://github.com/mfsga/Chimera_Client" ]; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + ]; + + path = [ + pkgs.iproute2 + ]; + + environment = cfg.environment; + + serviceConfig = { + Type = "simple"; + ExecStartPre = lib.optional cfg.checkConfig ( + escapeExecArgs (commonArgs ++ [ "--test-config" ]) + ); + ExecStart = escapeExecArgs (commonArgs ++ cfg.extraArgs); + + Restart = "on-failure"; + RestartSec = "3s"; + TimeoutStopSec = "30s"; + KillSignal = "SIGTERM"; + + DynamicUser = true; + StateDirectory = cfg.stateDirectory; + LoadCredential = [ "config.yaml:${cfg.configFile}" ]; + UMask = "0077"; + + AmbientCapabilities = capabilities; + CapabilityBoundingSet = capabilities; + + NoNewPrivileges = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateTmp = true; + PrivateMounts = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectHostname = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RestrictNamespaces = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "bpf" + ]; + + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ] ++ lib.optional cfg.tun.enable "AF_NETLINK"; + + PrivateDevices = !cfg.tun.enable; + PrivateUsers = !(cfg.tun.enable || cfg.processInspection); + ProtectProc = if cfg.processInspection then "default" else "invisible"; + ProcSubset = if cfg.processInspection then "all" else "pid"; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 00000000..d8b4c9a8 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,66 @@ +{ + lib, + rustPlatform, + pkg-config, + cmake, + protobuf, + llvmPackages, +}: + +rustPlatform.buildRustPackage { + pname = "chimera-client"; + version = "0.23.0"; + + src = lib.cleanSourceWith { + src = ../.; + filter = + path: type: + let + baseName = baseNameOf path; + in + !( + baseName == "target" + || baseName == "ref" + || baseName == "logs" + || baseName == "nix" + || baseName == ".git" + || baseName == "result" + || lib.hasPrefix "result-" baseName + || lib.hasSuffix ".log" baseName + ); + }; + + cargoLock = { + lockFile = ../Cargo.lock; + # This repository is distributed outside nixpkgs and currently contains + # pinned Cargo git dependencies. Replace this with outputHashes before a + # future nixpkgs submission. + allowBuiltinFetchGit = true; + }; + + nativeBuildInputs = [ + pkg-config + cmake + protobuf + llvmPackages.libclang + ]; + + LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; + + cargoBuildFlags = [ + "--package" + "clash-rs" + ]; + + # The workspace contains network-, Docker-, and privilege-dependent tests. + # They remain covered by CI and NixOS VM checks rather than the package build. + doCheck = false; + + meta = { + description = "Rust rule-based proxy client with DNS, TUN and Clash-compatible APIs"; + homepage = "https://github.com/mfsga/Chimera_Client"; + license = lib.licenses.asl20; + mainProgram = "clash-rs"; + platforms = lib.platforms.linux; + }; +} diff --git a/ref b/ref index 344176af..3ed728b9 160000 --- a/ref +++ b/ref @@ -1 +1 @@ -Subproject commit 344176afbb6743554e7920e46150567f9899d343 +Subproject commit 3ed728b9d54fd29049bb09c715a76ff9d0c163ff diff --git a/start.sh b/start.sh old mode 100644 new mode 100755 index 0701ae8d..4ef23ee8 --- a/start.sh +++ b/start.sh @@ -1,2 +1,40 @@ -cargo watch -x "run --package clash-rs --bin clash-rs -- -c config.yaml" -# cargo watch -x "run " +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT_DIR" + +CONFIG_FILE="${CONFIG_FILE:-config-prod.yaml}" +LOG_DIR="${LOG_DIR:-logs}" +RUN_ID="$(date '+%Y%m%d-%H%M%S')" + +mkdir -p "$LOG_DIR" + +APP_LOG_FILE="${APP_LOG_FILE:-$ROOT_DIR/$LOG_DIR/chimera-$RUN_ID.log}" +CONSOLE_LOG_FILE="${CONSOLE_LOG_FILE:-$ROOT_DIR/$LOG_DIR/chimera-$RUN_ID.console.log}" + +# clash-lib currently opens --log-file in append-only mode, so create it first. +touch "$APP_LOG_FILE" "$CONSOLE_LOG_FILE" + +echo "config: $CONFIG_FILE" +echo "app log: $APP_LOG_FILE" +echo "console log: $CONSOLE_LOG_FILE" + +# Keep Cargo's target directory owned by the developer. Only the already-built +# binary needs elevated privileges when TUN setup is enabled. +nix develop --command cargo build -p clash-rs +nix develop --command "$ROOT_DIR/target/debug/clash-rs" -t -c "$CONFIG_FILE" + +run_command=( + "$ROOT_DIR/target/debug/clash-rs" + -c "$CONFIG_FILE" + --log-file "$APP_LOG_FILE" +) + +if [[ "${RUN_AS_ROOT:-1}" == "1" ]]; then + nix develop --command sudo -- "${run_command[@]}" \ + 2>&1 | tee -a "$CONSOLE_LOG_FILE" +else + nix develop --command "${run_command[@]}" \ + 2>&1 | tee -a "$CONSOLE_LOG_FILE" +fi diff --git a/xhttp-h2-phaseb/src/bin/server.rs b/xhttp-h2-phaseb/src/bin/server.rs index 66d0419b..10250f52 100644 --- a/xhttp-h2-phaseb/src/bin/server.rs +++ b/xhttp-h2-phaseb/src/bin/server.rs @@ -79,10 +79,10 @@ async fn handle( } } Method::POST => { - if sid.is_none() { - Ok(handle_stream_one(req).await) + if let Some(sid) = sid { + Ok(handle_packet_up(req, state, sid, seq).await) } else { - Ok(handle_packet_up(req, state, sid.unwrap(), seq).await) + Ok(handle_stream_one(req).await) } } _ => Ok(simple(StatusCode::METHOD_NOT_ALLOWED)), @@ -133,21 +133,15 @@ async fn handle_stream_down( let (tx, rx) = mpsc::channel::, Infallible>>(32); let store = state.sessions.clone(); - let sid2 = sid.clone(); let queue = session.queue.clone(); tokio::spawn(async move { - loop { - match queue.read_chunk().await { - Some(bytes) => { - if tx.send(Ok(Frame::data(bytes))).await.is_err() { - break; - } - } - None => break, + while let Some(bytes) = queue.read_chunk().await { + if tx.send(Ok(Frame::data(bytes))).await.is_err() { + break; } } - let _ = store.remove(&sid2).await; + let _ = store.remove(&sid).await; }); Response::builder()