diff --git a/.github/workflows/publish-rust.yml b/.github/workflows/publish-rust.yml index 1317738..4eacdf6 100644 --- a/.github/workflows/publish-rust.yml +++ b/.github/workflows/publish-rust.yml @@ -10,6 +10,9 @@ on: paths: - "rust/Cargo.toml" - "rust/Cargo.lock" + - "web/**" + - "ddns/config/field-model.json" + - "docker/rust.Dockerfile" - ".github/workflows/publish-rust.yml" workflow_dispatch: @@ -107,6 +110,8 @@ jobs: binary="rust/target/${{ matrix.target }}/release/ddns-rs" "$binary" --version "$binary" --help + "$binary" web --help + "$binary" mcp --help "$binary" --dns debug --no-cache --index4 "shell:echo 192.0.2.44" --ipv4 test.example.com - name: Smoke test Rust Windows binary if: runner.os == 'Windows' @@ -115,6 +120,10 @@ jobs: $binary = "rust\target\${{ matrix.target }}\release\ddns-rs.exe" & $binary --version & $binary --help + & $binary web --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $binary mcp --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & $binary --dns debug --no-cache --index4 "shell:echo 192.0.2.44" --ipv4 test.example.com - name: Package Unix artifact and checksum if: runner.os != 'Windows' diff --git a/.github/workflows/rust-python-checks.yml b/.github/workflows/rust-python-checks.yml index b1f11c2..448cea3 100644 --- a/.github/workflows/rust-python-checks.yml +++ b/.github/workflows/rust-python-checks.yml @@ -5,6 +5,8 @@ on: branches: ["master", "main", "v5"] paths: - "rust/**" + - "web/**" + - "ddns/config/field-model.json" - ".github/workflows/rust.yml" - ".github/workflows/rust-python-checks.yml" - ".github/workflows/publish-rust.yml" @@ -24,6 +26,8 @@ on: branches: ["master", "main", "abc", "v5"] paths: - "rust/**" + - "web/**" + - "ddns/config/field-model.json" - ".github/workflows/rust.yml" - ".github/workflows/rust-python-checks.yml" - ".github/workflows/publish-rust.yml" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 860af74..babe383 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -5,6 +5,8 @@ on: branches: ["master", "main", "v5"] paths: - "rust/**" + - "web/**" + - "ddns/config/field-model.json" - ".github/workflows/rust.yml" - ".github/workflows/rust-python-checks.yml" - ".github/workflows/publish-rust.yml" @@ -24,6 +26,8 @@ on: branches: ["master", "main", "abc", "v5"] paths: - "rust/**" + - "web/**" + - "ddns/config/field-model.json" - ".github/workflows/rust.yml" - ".github/workflows/rust-python-checks.yml" - ".github/workflows/publish-rust.yml" @@ -97,6 +101,8 @@ jobs: with: workspaces: rust save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v5') }} + - name: Run native unit and process tests + run: cargo test --manifest-path rust/Cargo.toml --locked - name: Install musl toolchain if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools @@ -114,6 +120,8 @@ jobs: binary="rust/target/${{ matrix.target }}/release/ddns-rs" "$binary" --version "$binary" --help + "$binary" web --help + "$binary" mcp --help "$binary" --dns debug --no-cache \ --index4 "shell:echo 192.0.2.44" --ipv4 v4.example.com \ --index6 "shell:echo 2001:db8::44" --ipv6 v6.example.com @@ -124,6 +132,10 @@ jobs: $binary = "rust\target\${{ matrix.target }}\release\ddns-rs.exe" & $binary --version & $binary --help + & $binary web --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $binary mcp --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & $binary --dns debug --no-cache ` --index4 "shell:echo 192.0.2.44" --ipv4 v4.example.com ` --index6 "shell:echo 2001:db8::44" --ipv6 v6.example.com @@ -161,6 +173,8 @@ jobs: run: | docker run --rm --platform ${{ matrix.platform }} ddns-rs:test --version docker run --rm --platform ${{ matrix.platform }} ddns-rs:test --help + docker run --rm --platform ${{ matrix.platform }} ddns-rs:test web --help + docker run --rm --platform ${{ matrix.platform }} ddns-rs:test mcp --help docker run --rm --platform ${{ matrix.platform }} ddns-rs:test --dns debug --no-cache \ --index4 "shell:echo 192.0.2.44" --ipv4 v4.example.com \ --index6 "shell:echo 2001:db8::44" --ipv6 v6.example.com diff --git a/README.en.md b/README.en.md index 65c9a91..c66ab9b 100644 --- a/README.en.md +++ b/README.en.md @@ -77,7 +77,11 @@ cargo build --manifest-path rust/Cargo.toml --release --locked rust/target/release/ddns-rs -c config.json ``` -V5 currently performs one update run, without `task`, Web, MCP, or a scheduler. +V5 supports one-shot updates, the `web` dashboard with an in-process scheduler, +and `mcp` over stdio/HTTP; Web also serves `/mcp`. Web/MCP manage one local +configuration only; non-loopback HTTP listeners require a token. +The `task` command and OS task detection/takeover are not ported: manually +disable old Python/host tasks before enabling Rust scheduled updates. See the [Rust development guide](docs/en/dev/rust.md) for architecture, validation, support status, and the parity roadmap. ## Why it works for long-running deployments diff --git a/README.md b/README.md index cef2e74..fc08dd8 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,9 @@ cargo build --manifest-path rust/Cargo.toml --release --locked rust/target/release/ddns-rs -c config.json ``` -V5 目前只执行单次同步,不包含 `task`、Web、MCP 或内置调度。 +V5 支持单次同步、`web` 控制台与进程内定时同步,以及 `mcp` stdio/HTTP 服务; +Web 同时提供 `/mcp`。Web/MCP 仅管理单个本地配置,非回环 HTTP 监听必须设置 token。 +尚未迁移 `task` 或系统任务检测/接管;启用 Rust 定时同步前必须手动停用旧 Python/主机任务。 架构、验证命令、支持矩阵和迁移路线见 [Rust 开发文档](docs/dev/rust.md)。 ## 为什么适合长期运行 diff --git a/docker/rust.Dockerfile b/docker/rust.Dockerfile index 23f94cb..699539b 100644 --- a/docker/rust.Dockerfile +++ b/docker/rust.Dockerfile @@ -1,10 +1,12 @@ FROM rust:alpine AS builder -WORKDIR /build +WORKDIR /build/rust COPY rust/Cargo.toml rust/Cargo.lock ./ COPY rust/src ./src +COPY web/index.html web/dashboard.js web/dashboard.css web/ddns.svg /build/web/ +COPY ddns/config/field-model.json /build/ddns/config/field-model.json RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/build/target \ + --mount=type=cache,target=/build/rust/target \ cargo build --release --locked \ && cp target/release/ddns-rs /tmp/ddns-rs diff --git a/docs/dev/rust.md b/docs/dev/rust.md index f298e3b..8d31d54 100644 --- a/docs/dev/rust.md +++ b/docs/dev/rust.md @@ -2,7 +2,7 @@ `ddns-rs` 是 DDNS 下一主版本 V5 的 Rust 实现,集成分支为 `v5`。 Python 版本继续在 `master` / `v4` 维护;Rust 功能 PR 应以 `v5` 为目标。 -当前开发版本为 `5.0.0-alpha1`,只负责单次同步,尚未发布,也不替换稳定的 +当前开发版本为 `5.0.0-alpha1`,支持单次同步及 Web/MCP 服务,尚未发布,也不替换稳定的 Python `ddns` 命令或默认安装方式。 ## 当前支持 @@ -14,15 +14,18 @@ Python `ddns` 命令或默认安装方式。 | CLI、`DDNS_*`、本地/远程/多配置、v4.1 `providers` | 已支持 | | JSON 注释与受限 Python 数据字面量 | 已支持 | | 缓存、代理回退、重试、TLS 与自定义 CA | 已支持 | -| Linux amd64/arm64 Docker 镜像 | 构建和离线冒烟测试;准备流程保存 OCI 工件,不推送公共镜像,无内置调度 | +| Linux amd64/arm64 Docker 镜像 | 构建和离线冒烟测试;准备流程保存 OCI 工件,不推送公共镜像;`web` 提供进程内调度 | | Linux x64/arm64、macOS x64/arm64、Windows x64 二进制 | 准备流程保存 `ddns-rs-*` 与 `.sha256` Actions 工件;Linux 使用静态 musl 目标 | -| `task`、Web、MCP | 计划中 | +| Web 控制台、配置管理与进程内调度 | 已支持;单个本地配置,间隔 1..1440 分钟 | +| MCP stdio、独立 HTTP、Web `/mcp` | 已支持;现代协议及旧版 stdio 兼容 | +| `task` 与系统任务检测/接管 | 尚未迁移;必须手动停用旧 Python/主机任务 | ## V5 分支与发布准备 保留 `rust/` 布局、`ddns-rs` 命令及现有配置兼容边界,暂不删除 Python 实现。 `rust/Cargo.toml` 与 `rust/Cargo.lock` 的版本必须一致。`v5` 上的版本文件或 -准备工作流发生变更时,`Prepare Rust V5` 自动构建,无需创建标签。它也接受 +准备工作流、内嵌 Web 资源/字段模型或 Rust Dockerfile 发生变更时, +`Prepare Rust V5` 自动构建,无需创建标签。它也接受 `v5` 分支上的手动准备,或与 Cargo 版本一致的规范 `v5.*` 标签,并生成经过 校验的原生工件和双架构 OCI 工件;不会创建 Release、上传发布资产或推送镜像。 准备流程仅接受带 `alpha`、`beta` 或 `rc` 后缀的 V5 预发布版本,不接受稳定版本。 @@ -48,6 +51,47 @@ Windows 产物为 `rust\target\release\ddns-rs.exe`。 实验性 `install-rust.sh` 在移动临时二进制到安装目录前执行 `--version`; 新程序无法运行时,即使使用 `--force` 也不会覆盖已有的 `ddns-rs`。 +## Web 与 MCP + +```bash +ddns-rs web -c config.json --host 127.0.0.1 --port 8000 --interval 5 --open +ddns-rs mcp -c config.json +ddns-rs mcp -c config.json --transport http --host 127.0.0.1 --port 8001 +``` + +`web` 接受 `-c FILE`、`--host HOST`、`--port PORT`、`--http-token TOKEN`、 +可重复的 `--http-origin ORIGIN`、`--interval MINUTES` 和 `--open`(打开浏览器)。 +未指定子命令时,顶层 `--interval` 或配置根级 `interval` 会选择 Web 模式。 +进程内调度间隔为 1..1440 分钟,仅在进程存活期间运行。 + +控制台支持读取、校验和保存完整配置,管理备份/恢复、查看状态并触发同步。 +Web 与 MCP 复用同一个服务,仅接受单个本地配置文件,不接受远程或多配置来源。 +Web 使用现有兼容的 Rust 运行时校验配置,不代表与 Python 运行时完全等价。 + +`mcp` 默认使用按行分隔的 stdio。`--transport http` 在 `/mcp` 提供独立的 +Streamable HTTP;Web 监听器也提供相同端点。现代 MCP 协议版本为 +`2026-07-28`;旧版 `2025-11-25` 仅通过 stdio 兼容。两种服务均提供与 Python +相同的 `get_ddns_status`、`update_dns_records` 工具。 + +HTTP 默认监听回环地址。非回环监听必须设置 `--http-token TOKEN`,客户端使用 +`Authorization: Bearer TOKEN` 鉴权。可重复传入 `--http-origin ORIGIN` 添加允许的 +浏览器来源。Web 和 MCP HTTP 均执行来源检查与鉴权。不要将明文 HTTP 暴露于 +不可信网络,应使用可信的 TLS 反向代理或隧道。状态响应不包含凭据,但受监听器 +鉴权保护的配置 API 会按设计返回完整配置,包括 Provider 凭据。监听 token、配置和 +备份均应视为机密;即使不设置回环监听 token,本机访问仍是信任边界。 +监听 token 授予敏感配置访问与 DNS 更新权限,不是只读监控凭据。Web 同步会以 +进程用户权限执行经鉴权管理的本地配置中的 `cmd:`/`shell:` 规则,因此只应向可信 +管理员授予访问权限。 + +二进制在编译时内嵌现有 `web/` 控制台资源和 `ddns/config/field-model.json`, +提供页面无需前端服务器或 Python 运行时。修改这些输入后须重新构建;源码构建需 +保留仓库布局,不能只复制孤立的 `rust/` 目录。 + +**迁移限制:** 尚未实现 `task`、系统任务检测及自动接管。启用 Rust 定时同步前, +必须手动停用旧 Python/主机的 systemd、cron、launchd 或 schtasks 任务,避免重复运行。 +Web 调度器不会安装或替代主机服务。已有 Rust `regex:` 与独立缓存限制仍然适用; +`log_format`、`log_datefmt` 仅为配置兼容而接受,不保证 Python 日志格式等价。 + ## 架构 - `cli.rs`:兼容参数、别名和列表规则,不依赖 CLI 框架。 @@ -117,5 +161,5 @@ CI 还会在 Linux x64/arm64、Windows x64、macOS x64/arm64 构建、测试并 ## 后续顺序 1. 迁移 systemd、cron、launchd、schtasks。 -2. 迁移 Web 控制台与 MCP。 +2. 验证 Web/MCP 在长期运行与平台迁移中的兼容性。 3. 达到完整等价并稳定运行后再讨论改名为 `ddns`。 diff --git a/docs/docker.md b/docs/docker.md index a1239e2..a024f09 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -16,8 +16,8 @@ 稳定默认镜像仍是 `newfuture/ddns` / `ghcr.io/newfuture/ddns`,不会被 Rust 镜像替换。Rust V5 在 `v5` 分支开发,目前只构建 Linux amd64/arm64 -OCI 工件,不推送公共镜像。它直接运行 `ddns-rs`,不包含 Python 镜像的 -内置定时任务。可从分支源码本地构建: +OCI 工件,不推送公共镜像。它直接运行 `ddns-rs`,不包含 Python 镜像的 cron +入口;默认单次同步,`web` 模式提供进程内定时同步。可从分支源码本地构建: ```bash docker build -f docker/rust.Dockerfile -t ddns-rs:v5-dev . @@ -26,6 +26,33 @@ docker run --rm ddns-rs:v5-dev --help V5 尚未发布,暂不提供公共 `latest` / `next` Rust 镜像入口。 +Web 示例(先准备含 `config.json` 的本地 `config/` 目录,并在 shell 中设置私密的 +`DDNS_HTTP_TOKEN`): + +```bash +docker run --rm -p 127.0.0.1:8000:8000 \ + -v "$PWD/config:/ddns" ddns-rs:v5-dev \ + web -c /ddns/config.json --host 0.0.0.0 --port 8000 \ + --http-token "$DDNS_HTTP_TOKEN" --http-origin http://127.0.0.1:8000 --interval 5 +``` + +容器内的非回环监听也必须设置 token;仅将发布端口绑定主机回环地址,客户端使用 +Bearer token 鉴权。若需远程访问,应使用可信 TLS 反向代理或隧道,并按需重复 +`--http-origin ORIGIN`。控制台与 `/mcp` 共用监听器和服务;独立 MCP HTTP 可改用 +`mcp -c /ddns/config.json --transport http` 并保留相同监听/鉴权选项。 +stdio MCP 使用 `docker run --rm -i`(不要分配 TTY),挂载配置后运行 `mcp -c /ddns/config.json`。 + +Web/MCP 仅接受单个本地配置。保存及备份/恢复需要容器非 root 用户 `ddns` 对挂载目录 +有写权限;请按部署环境配置权限,不要将其设为全局可写。配置 API 会返回完整配置, +包括凭据;配置、备份和监听 token 都应保密。镜像只内嵌明确列出的公共 Web 资源与 +字段模型,不复制用户配置。构建必须从仓库根目录提供上下文。 +监听 token 授予敏感配置访问与 DNS 更新权限。Web 同步会以容器用户身份执行 +本地配置中的 `cmd:`/`shell:` 规则,只应向可信管理员授予访问权限。 + +进程内调度间隔为 1..1440 分钟;退出容器即停止。尚未实现 `task` 或系统任务 +检测/接管,启用前必须手动停用旧 Python/主机任务。兼容限制和详细选项见 +[Rust V5 开发文档](dev/rust.md)。以下镜像版本和默认 cron 行为仅适用于稳定 Python 镜像。 + ### 镜像版本 DDNS 镜像版本(Docker Tag): diff --git a/docs/en/dev/rust.md b/docs/en/dev/rust.md index e3bfe02..fab8a98 100644 --- a/docs/en/dev/rust.md +++ b/docs/en/dev/rust.md @@ -2,8 +2,8 @@ `ddns-rs` is the Rust implementation for DDNS V5, integrated on the `v5` branch. Python maintenance continues on `master` / `v4`; Rust feature PRs should target -`v5`. The current development version is `5.0.0-alpha1`. It performs one -synchronization per process, is not published, and does not replace the stable +`v5`. The current development version is `5.0.0-alpha1`. It supports one-shot +updates and Web/MCP services, is not published, and does not replace the stable Python `ddns` command or default installer. ## Current support @@ -15,17 +15,20 @@ Python `ddns` command or default installer. | CLI, `DDNS_*`, local/remote/multiple configs, v4.1 `providers` | Supported | | JSON comments and restricted Python data literals | Supported | | Cache, proxy fallback, retries, TLS, and custom CA files | Supported | -| Linux amd64/arm64 Docker image | Build and offline smoke coverage; preparation saves an OCI artifact without registry publication or a scheduler | +| Linux amd64/arm64 Docker image | Build and offline smoke coverage; preparation saves an OCI artifact without registry publication; `web` provides in-process scheduling | | Linux x64/arm64, macOS x64/arm64, Windows x64 binaries | Preparation saves `ddns-rs-*` and `.sha256` Actions artifacts; Linux uses static musl targets | -| `task`, Web, and MCP | Planned | +| Web dashboard, configuration management, and in-process scheduling | Supported; one local config, interval 1..1440 minutes | +| MCP stdio, standalone HTTP, and Web `/mcp` | Supported; modern protocol and legacy stdio compatibility | +| `task` and OS task detection/takeover | Not ported; manually disable old Python/host tasks | ## V5 branch and release preparation Keep the `rust/` layout, `ddns-rs` command, and existing configuration boundaries; do not remove Python yet. The versions in `rust/Cargo.toml` and `rust/Cargo.lock` -must match. Changes to the version files or preparation workflow on `v5` -automatically run `Prepare Rust V5` without requiring a tag. It also accepts -manual preparation on `v5` or canonical `v5.*` tags matching Cargo, then produces verified native and two-platform OCI +must match. Changes to the version files, preparation workflow, embedded Web +assets/field model, or Rust Dockerfile on `v5` automatically run `Prepare Rust V5` +without requiring a tag. It also accepts manual preparation on `v5` or canonical +`v5.*` tags matching Cargo, then produces verified native and two-platform OCI artifacts. It does not create Releases, upload release assets, or push images. Preparation accepts only V5 prereleases with an `alpha`, `beta`, or `rc` suffix, not stable versions. @@ -54,6 +57,58 @@ The experimental `install-rust.sh` runs `--version` on the temporary binary before moving it into place. An artifact that cannot run leaves the existing `ddns-rs` untouched, even with `--force`. +## Web and MCP + +```bash +ddns-rs web -c config.json --host 127.0.0.1 --port 8000 --interval 5 --open +ddns-rs mcp -c config.json +ddns-rs mcp -c config.json --transport http --host 127.0.0.1 --port 8001 +``` + +`web` accepts `-c FILE`, `--host HOST`, `--port PORT`, `--http-token TOKEN`, +repeatable `--http-origin ORIGIN`, `--interval MINUTES`, and `--open` (open the +browser). Without an explicit subcommand, top-level `--interval` or a root +configuration `interval` selects Web mode. The in-process scheduler accepts +1..1440 minutes and runs only while the process is alive. + +The dashboard reads, validates, and saves the full configuration, manages +backups/restores, reports status, and triggers synchronization. Web and MCP use +the same service. They require a single local configuration file, not remote or +multiple configuration sources. Web validation uses the compatible Rust runtime; +it does not promise full Python runtime parity. + +`mcp` defaults to newline-delimited stdio. `--transport http` serves standalone +Streamable HTTP at `/mcp`; Web exposes the same endpoint on its own listener. +Modern MCP uses protocol `2026-07-28`; legacy `2025-11-25` is supported over stdio +only. Both expose the Python tool names `get_ddns_status` and +`update_dns_records`. + +HTTP listeners default to loopback. A non-loopback bind requires +`--http-token TOKEN`; clients authenticate with `Authorization: Bearer TOKEN`. +Use repeated `--http-origin ORIGIN` flags for additional allowed browser origins. +Origin checks and authentication apply to both Web and MCP HTTP. Do not expose +plain HTTP on an untrusted network; use a trusted TLS reverse proxy or tunnel. +Status responses exclude credentials, but the authenticated configuration API +intentionally returns the full configuration, including provider credentials. +Treat listener tokens, configuration files, and backups as secrets. With no +listener token on loopback, local access is still a trust boundary. +The listener token grants sensitive configuration access and DNS update authority, +not read-only monitoring. Web updates execute `cmd:`/`shell:` rules from the +authenticated local configuration with the process user's permissions; grant +access only to trusted administrators. + +The binary embeds the existing `web/` dashboard assets and +`ddns/config/field-model.json` at compile time; no frontend server or Python +runtime is needed to serve them. Rebuild after changing these inputs. Source +builds require the repository layout, not an isolated copy of `rust/`. + +**Migration limitation:** `task`, OS scheduler detection, and automatic takeover +are not implemented. Manually disable old Python/host systemd, cron, launchd, or +schtasks jobs before enabling Rust scheduled updates to avoid duplicate runs. +The Web scheduler does not install or replace a host service. Existing Rust +`regex:` and separate-cache limits still apply. `log_format` and `log_datefmt` +are accepted for configuration compatibility, not Python formatting parity. + ## Architecture - `cli.rs`: compatible options, aliases, and list rules without a CLI framework. @@ -150,6 +205,6 @@ Linux x64/arm64. ## Parity roadmap 1. Port systemd, cron, launchd, and schtasks support. -2. Port the Web dashboard and MCP server. +2. Validate Web/MCP compatibility in long-running deployments and platform migrations. 3. Consider renaming the binary to `ddns` only after complete parity and a separate stability period. diff --git a/docs/en/docker.md b/docs/en/docker.md index faa8330..0eef3e7 100644 --- a/docs/en/docker.md +++ b/docs/en/docker.md @@ -17,8 +17,9 @@ The stable default image remains `newfuture/ddns` / `ghcr.io/newfuture/ddns`; the Rust image does not replace it. Rust V5 is developed on `v5` and currently produces Linux amd64/arm64 OCI artifacts without pushing a public image. -It runs `ddns-rs` directly without the Python image's built-in scheduler. -Build it locally from the branch source: +It runs `ddns-rs` directly without the Python image's cron entrypoint. The default +is one-shot synchronization; `web` enables in-process scheduling. Build it locally +from the branch source: ```bash docker build -f docker/rust.Dockerfile -t ddns-rs:v5-dev . @@ -27,6 +28,41 @@ docker run --rm ddns-rs:v5-dev --help V5 has not been published; no public `latest` / `next` Rust channel is promised yet. +Web example (first prepare a local `config/` directory containing `config.json` +and set a private `DDNS_HTTP_TOKEN` in your shell): + +```bash +docker run --rm -p 127.0.0.1:8000:8000 \ + -v "$PWD/config:/ddns" ddns-rs:v5-dev \ + web -c /ddns/config.json --host 0.0.0.0 --port 8000 \ + --http-token "$DDNS_HTTP_TOKEN" --http-origin http://127.0.0.1:8000 --interval 5 +``` + +A non-loopback bind inside the container also requires a token. Publish the port +only on host loopback; clients authenticate with a Bearer token. For remote +access, use a trusted TLS reverse proxy or tunnel and repeat `--http-origin ORIGIN` +as needed. The dashboard and `/mcp` share the listener and service. For standalone +MCP HTTP, use `mcp -c /ddns/config.json --transport http` with the same listener/auth +options. For stdio MCP, use `docker run --rm -i` (without a TTY), mount the config, +and run `mcp -c /ddns/config.json`. + +Web/MCP require one local configuration. Saving and backup/restore require the +non-root container user `ddns` to have write access to the mounted directory; +configure deployment-specific permissions rather than making it world-writable. +The configuration API returns the full configuration, including credentials; +keep configurations, backups, and listener tokens private. The image embeds only +explicit public Web assets and the field model, not user configuration files. +Build with the repository root as context. +Listener tokens grant sensitive configuration access and DNS update authority. +Web synchronization executes local configuration `cmd:`/`shell:` rules as the +container user; only trusted administrators should receive access. + +The in-process interval is 1..1440 minutes; scheduling stops when the container +exits. Neither `task` nor OS task detection/takeover is implemented: manually +disable old Python/host tasks first. See the [Rust V5 development guide](dev/rust.md) +for options and compatibility limits. The image versions and default cron +behavior below apply only to the stable Python image. + ### Image Versions DDNS image versions (Docker Tags): diff --git a/docs/en/install.md b/docs/en/install.md index f3e9bb8..170526d 100644 --- a/docs/en/install.md +++ b/docs/en/install.md @@ -31,6 +31,20 @@ cargo build --manifest-path rust/Cargo.toml --release --locked rust/target/release/ddns-rs --help ``` +Source builds and CI binaries support Web and MCP: + +```bash +rust/target/release/ddns-rs web -c config.json --interval 5 +rust/target/release/ddns-rs mcp -c config.json +``` + +Web embeds the dashboard and exposes `/mcp`; standalone HTTP MCP uses +`mcp --transport http`. Web/MCP manage one local config only; non-loopback HTTP +listeners require `--http-token`. In-process scheduling does not migrate or +detect OS tasks: manually disable old Python/host tasks to avoid duplicate runs. +See the [Rust V5 development guide](dev/rust.md) for options, security boundaries, +and compatibility limits. + The retained Rust installer is future release tooling for Linux x64/arm64 and macOS x64/arm64. It downloads the separate `ddns-rs-linux-x64`, `ddns-rs-linux-arm64`, `ddns-rs-macos-x64`, diff --git a/docs/install.md b/docs/install.md index 62db7db..332351c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,6 +30,18 @@ cargo build --manifest-path rust/Cargo.toml --release --locked rust/target/release/ddns-rs --help ``` +源码或 CI 二进制支持 Web 与 MCP: + +```bash +rust/target/release/ddns-rs web -c config.json --interval 5 +rust/target/release/ddns-rs mcp -c config.json +``` + +Web 内嵌控制台并提供 `/mcp`;独立 HTTP MCP 使用 `mcp --transport http`。 +Web/MCP 仅管理单个本地配置,非回环 HTTP 监听必须设置 `--http-token`。 +进程内调度不迁移或检测系统任务;必须手动停用旧 Python/主机任务,避免重复运行。 +详细选项、安全边界及兼容限制见 [Rust V5 开发文档](dev/rust.md)。 + 保留的 Rust 安装脚本是未来发布工具,仅支持 Linux x64/arm64 和 macOS x64/arm64,下载独立的 `ddns-rs-linux-x64`、`ddns-rs-linux-arm64`、`ddns-rs-macos-x64` 或 `ddns-rs-macos-arm64` Release 资产及其 `.sha256` 校验文件。Windows x64 diff --git a/rust/README.md b/rust/README.md index 1900ddd..eaecb26 100644 --- a/rust/README.md +++ b/rust/README.md @@ -19,10 +19,14 @@ replace the stable Python `ddns` command. - Local, remote, multiple, JSONC, and restricted Python-literal configurations - `default`, `public`, numeric, `url:`, `regex:`, `cmd:`, and `shell:` address rule types - Cache, proxy fallback, retries, custom CA files, and existing TLS policies -- One update run per process on Linux, macOS, and Windows +- One-shot updates on Linux, macOS, and Windows +- Embedded Web dashboard, configuration management, and in-process scheduled updates +- MCP stdio and HTTP transports, with `/mcp` also available on the Web listener -The `task`, `web`, and `mcp` commands are not implemented yet. The Linux -amd64/arm64 container runs `ddns-rs` once and has no scheduler. Native artifacts +The `task` command and OS scheduler migration are not implemented yet. The Linux +amd64/arm64 container defaults to one-shot updates; `web` enables its in-process +scheduler. Manually disable old Python/host tasks before enabling Rust scheduled +updates: Rust does not detect or take over those tasks. Native artifacts are named `ddns-rs-linux-x64`, `ddns-rs-linux-arm64`, `ddns-rs-macos-x64`, `ddns-rs-macos-arm64`, and `ddns-rs-windows-x64.exe`. Linux uses static musl targets so the same names work on glibc and musl distributions. @@ -32,7 +36,8 @@ targets so the same names work on glibc and musl distributions. The Rust CI validates `v5` commits and pull requests targeting `v5`. `Prepare Rust V5` builds and verifies native artifacts with SHA-256 checksums and a two-platform OCI archive. Changes to the Cargo version files or this -workflow on `v5` automatically start preparation without needing a tag. It also +workflow, embedded Web assets/field model, or Rust Dockerfile on `v5` automatically +start preparation without needing a tag. It also accepts manual preparation on `v5` or a canonical `v5.*` tag matching both Cargo version files. Outputs stay in GitHub Actions artifacts; it does not create a release, upload release assets, @@ -74,6 +79,58 @@ When a reused configuration specifies a custom `cache` path, `ddns-rs` writes it versioned format to the sibling `.ddns-rs` file. The Python cache at the configured path is never modified. +## Web and MCP + +```bash +ddns-rs web -c config.json --host 127.0.0.1 --port 8000 --interval 5 --open +ddns-rs mcp -c config.json +ddns-rs mcp -c config.json --transport http --host 127.0.0.1 --port 8001 +``` + +`web` accepts `-c FILE`, `--host HOST`, `--port PORT`, `--http-token TOKEN`, +repeatable `--http-origin ORIGIN`, `--interval MINUTES`, and `--open` (open the +browser). Without an explicit subcommand, top-level `--interval` or a root +configuration `interval` selects Web mode. The in-process scheduler accepts +1..1440 minutes and runs only while the process is alive. + +The dashboard reads, validates, and saves the full configuration, manages +backups/restores, reports status, and triggers synchronization. Web and MCP use +the same service. They require a single local configuration file, not remote or +multiple configuration sources. Web validation uses the compatible Rust runtime; +it does not promise full Python runtime parity. + +`mcp` defaults to newline-delimited stdio. `--transport http` serves standalone +Streamable HTTP at `/mcp`; Web exposes the same endpoint on its own listener. +Modern MCP uses protocol `2026-07-28`; legacy `2025-11-25` is supported over stdio +only. Both expose the Python tool names `get_ddns_status` and +`update_dns_records`. + +HTTP listeners default to loopback. A non-loopback bind requires +`--http-token TOKEN`; clients authenticate with `Authorization: Bearer TOKEN`. +Use repeated `--http-origin ORIGIN` flags for additional allowed browser origins. +Origin checks and authentication apply to both Web and MCP HTTP. Do not expose +plain HTTP on an untrusted network; use a trusted TLS reverse proxy or tunnel. +Status responses exclude credentials, but the authenticated configuration API +intentionally returns the full configuration, including provider credentials. +Treat listener tokens, configuration files, and backups as secrets. With no +listener token on loopback, local access is still a trust boundary. +The listener token grants sensitive configuration access and DNS update authority, +not read-only monitoring. Web updates execute `cmd:`/`shell:` rules from the +authenticated local configuration with the process user's permissions; grant +access only to trusted administrators. + +The binary embeds the existing `web/` dashboard assets and +`ddns/config/field-model.json` at compile time; no frontend server or Python +runtime is needed to serve them. Rebuild after changing these inputs. Source +builds require the repository layout, not an isolated copy of `rust/`. + +**Migration limitation:** `task`, OS scheduler detection, and automatic takeover +are not implemented. Manually disable old Python/host systemd, cron, launchd, or +schtasks jobs before enabling Rust scheduled updates to avoid duplicate runs. +The Web scheduler does not install or replace a host service. Existing Rust +`regex:` and separate-cache limits still apply. `log_format` and `log_datefmt` +are accepted for configuration compatibility, not Python formatting parity. + ## Validate ```bash diff --git a/rust/src/cache.rs b/rust/src/cache.rs index b14fe5c..563566a 100644 --- a/rust/src/cache.rs +++ b/rust/src/cache.rs @@ -58,6 +58,10 @@ impl Cache { Ok(Some(cache)) } + pub(crate) fn modified(&self) -> Option { + fs::metadata(&self.path).ok()?.modified().ok() + } + pub fn get(&self, provider: ProviderId, domain: &str, record_type: &str) -> Option<&str> { self.records .get(&cache_key(&self.namespace, provider, domain, record_type)) diff --git a/rust/src/cli.rs b/rust/src/cli.rs index bcc6bd6..349bb7e 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -9,6 +9,8 @@ pub const HELP: &str = "\ DDNS Rust client Usage: ddns-rs [OPTIONS] + ddns-rs web [OPTIONS] + ddns-rs mcp [OPTIONS] Options: -c, --config ... Load local or remote configuration files @@ -361,8 +363,8 @@ mod tests { } #[test] - fn rejects_mvp_subcommands() { - let error = parse(["ddns-rs", "web"]).unwrap_err(); + fn rejects_unsupported_task_command() { + let error = parse(["ddns-rs", "task"]).unwrap_err(); assert!(error.to_string().contains("not supported")); } diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index 9a0a6c0..5bce34d 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -117,6 +117,17 @@ pub fn load( .collect() } +/// Merge an already parsed document using the same precedence as the CLI. +pub fn from_document( + document: Value, + environment: &BTreeMap, +) -> Result> { + merge::expand_document(document)? + .iter() + .map(|document| Config::from_sources(&BTreeMap::new(), document, environment, false)) + .collect() +} + fn config_paths(cli: &CliOptions, environment: &BTreeMap) -> Result> { if let Some(paths) = &cli.config_paths { return Ok(paths.clone()); diff --git a/rust/src/dashboard/mod.rs b/rust/src/dashboard/mod.rs new file mode 100644 index 0000000..7632cc1 --- /dev/null +++ b/rust/src/dashboard/mod.rs @@ -0,0 +1,415 @@ +mod scheduler; +mod storage; +mod validation; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use crate::cache::Cache; +use crate::config::{self, Config}; +use crate::logging::{Level, Logger}; +use crate::signature::sha256_hex; +use crate::update; + +use scheduler::Schedule; +pub use scheduler::SchedulerHandle; +use validation::{MODEL, invalid}; + +#[derive(Debug, Clone)] +pub struct DashboardError { + pub status: u16, + pub code: &'static str, + pub message: String, +} +impl fmt::Display for DashboardError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} +impl std::error::Error for DashboardError {} +pub type Result = std::result::Result; + +pub(super) fn operation_error(_error: impl fmt::Display) -> DashboardError { + // Paths, parser errors, and provider errors can contain credentials. + DashboardError { + status: 500, + code: "operation_failed", + message: "The dashboard operation could not be completed.".to_owned(), + } +} + +pub(super) fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +#[derive(Default)] +struct State { + identity: String, + last_sync: Option, + failed: BTreeSet, + successful: BTreeSet, + cancelled: bool, + records: BTreeMap<(usize, String, String), (String, f64)>, + activities: Vec, +} +impl State { + fn activity(&mut self, source: &str, message: &str, error: bool) { + let source = match source { + "web" | "mcp" | "scheduler" | "config" => source, + _ => "manual", + }; + self.activities.insert(0, json!({"level":if error {"WARN"} else {"INFO"}, "source":source, "message":message, "detail":"", "timestamp":now()})); + self.activities.truncate(50); + } +} + +pub struct DashboardService { + path: PathBuf, + state: Mutex, + schedule: Arc, +} + +impl DashboardService { + pub fn new(config_path: Option, interval: u16) -> Result { + validation::interval(interval)?; + let path = config_path.unwrap_or_else(|| { + config::file::existing_default() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("config.json")) + }); + if path.to_string_lossy().contains("://") { + return Err(invalid("dashboard configuration must be a local file")); + } + let path = config::file::expand_home(&path.to_string_lossy()); + let path = if path.is_absolute() { + path + } else { + std::env::current_dir().map_err(operation_error)?.join(path) + }; + let service = Self { + path, + state: Mutex::new(State::default()), + schedule: Arc::new(Schedule::new(interval)), + }; + Ok(service) + } + + pub fn config_path(&self) -> &Path { + &self.path + } + + fn lock(&self) -> Result> { + self.state.lock().map_err(operation_error) + } + + pub fn validate(&self, document: Value) -> Result { + validation::validate(document, &config::env::load()) + } + + fn parse(&self, content: &str) -> Result { + self.validate(config::file::parse(content).map_err(|_| { + invalid("configuration is not valid JSON or a supported legacy literal") + })?) + } + + fn load_document(&self) -> Result { + match fs::read_to_string(&self.path) { + Ok(content) => self.parse(&content), + Err(error) if error.kind() == io::ErrorKind::NotFound => self.validate(json!({})), + Err(error) => Err(operation_error(error)), + } + } + + fn runtime(&self) -> Result> { + config::from_document(self.load_document()?, &config::env::load()) + .map_err(|_| invalid("invalid runtime configuration")) + } + + pub fn config_state(&self) -> Result { + let _state = self.lock()?; + self.config_state_locked() + } + + fn config_state_locked(&self) -> Result { + let mut state = json!({ + "config":{"$schema":MODEL["schema"]["url"], "providers":[]}, + "model": *MODEL, "path":self.path, "exists":self.path.exists(), + "backup_available":storage::backup(&storage::target(&self.path)?).is_file(), + }); + let content = match fs::read_to_string(&self.path) { + Ok(content) => content, + Err(error) if error.kind() == io::ErrorKind::NotFound => "{}".to_owned(), + Err(error) => return Err(operation_error(error)), + }; + match self.parse(&content) { + Ok(document) => state["config"] = document, + Err(error) => { + state["validation_error"] = json!(error.message); + state["raw"] = json!(content); + } + } + Ok(state) + } + + pub fn save(&self, document: Value) -> Result { + let mut state = self.lock()?; + let document = self.validate(document)?; + let content = format!( + "{}\n", + serde_json::to_string_pretty(&document).map_err(operation_error)? + ); + storage::write(&self.path, content.as_bytes())?; + self.apply_interval(&document); + *state = State::default(); + state.activity("config", "Configuration saved", false); + self.config_state_locked() + } + + pub fn restore_backup(&self) -> Result { + let mut state = self.lock()?; + let backup = storage::backup(&storage::target(&self.path)?); + let content = fs::read_to_string(backup).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + DashboardError { + status: 404, + code: "backup_not_found", + message: "No configuration backup is available.".to_owned(), + } + } else { + operation_error(error) + } + })?; + let document = self.parse(&content)?; + storage::restore(&self.path, content.as_bytes())?; + self.apply_interval(&document); + *state = State::default(); + state.activity("config", "Configuration backup restored", false); + self.config_state_locked() + } + + fn apply_interval(&self, document: &Value) { + if let Some(interval) = document.get("interval").and_then(Value::as_u64) { + self.schedule.configure(None, interval as u16); + } + } + + pub fn dashboard(&self) -> Result { + let mut state = self.lock()?; + self.dashboard_locked(&mut state) + } + + fn dashboard_locked(&self, state: &mut State) -> Result { + let mut result = json!({"state":"unconfigured", "message":"Add at least one domain to synchronize", "config_path":self.path, + "last_sync":null, "addresses":[], "providers":[], "records":[], "activities":state.activities, "scheduler":self.schedule.status()}); + let configs = match self.runtime() { + Ok(configs) => configs, + Err(_) => { + result["state"] = json!("error"); + result["message"] = + json!("Configuration needs repair; open the configuration API for details"); + return Ok(result); + } + }; + ensure_identity(state, &configs)?; + let mut providers = Vec::new(); + let mut records = Vec::new(); + let mut addresses = BTreeSet::new(); + let mut configured = 0; + let mut last_sync = state.last_sync; + for (index, config) in configs.iter().enumerate() { + let cache = Cache::open( + &config.cache, + &config.cache_identity(), + config.cache_max_age, + Logger::stderr(Level::Critical, Vec::new()), + ) + .ok() + .flatten(); + let cached_time = cache + .as_ref() + .and_then(Cache::modified) + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs_f64()); + let mut cached = false; + for (domains, record_type) in [(&config.ipv4, "A"), (&config.ipv6, "AAAA")] { + configured += domains.len(); + for domain in domains { + let domain = domain.to_ascii_lowercase(); + let memory = + state + .records + .get(&(index, domain.clone(), record_type.to_owned())); + let address = memory.map(|record| record.0.as_str()).or_else(|| { + cache + .as_ref() + .and_then(|cache| cache.get(config.provider, &domain, record_type)) + }); + if let Some(address) = + address.and_then(|address| address.parse::().ok()) + { + if (record_type == "A") != address.is_ipv4() { + continue; + } + let address = address.to_string(); + cached = true; + let updated = memory.map(|record| record.1).or(cached_time); + if let Some(updated) = updated { + last_sync = + Some(last_sync.map_or(updated, |previous| previous.max(updated))); + } + records.push(json!({"domain":domain,"type":record_type,"value":address,"provider":config.provider.as_str(),"provider_index":index,"updated":updated})); + addresses + .insert((if record_type == "A" { "IPv4" } else { "IPv6" }, address)); + } + } + } + let label = MODEL["providers"] + .as_array() + .and_then(|providers| { + providers + .iter() + .find(|p| p["id"] == config.provider.as_str()) + }) + .and_then(|p| p["name"].as_str()) + .unwrap_or(config.provider.as_str()); + providers.push(json!({"id":config.provider.as_str(),"index":index,"label":label,"records":config.ipv4.len()+config.ipv6.len(),"status":if state.failed.contains(&index) {"error"} else if cached || state.successful.contains(&index) {"synced"} else {"configured"}})); + } + let (status, message) = if configured == 0 { + ("unconfigured", "Add at least one domain to synchronize") + } else if !state.failed.is_empty() { + ("error", "Some providers failed to synchronize") + } else if state.cancelled { + ("error", "Synchronization cancelled") + } else if !records.is_empty() || state.last_sync.is_some() { + ("synced", "Synchronization data available") + } else { + ("ready", "Configuration ready for synchronization") + }; + records.sort_by_key(|record| { + ( + record["domain"].as_str().unwrap_or("").to_owned(), + record["type"].as_str().unwrap_or("").to_owned(), + record["provider_index"].as_u64(), + ) + }); + result["state"] = json!(status); + result["message"] = json!(message); + result["last_sync"] = json!(last_sync); + result["providers"] = json!(providers); + result["records"] = json!(records); + result["addresses"] = json!( + addresses + .into_iter() + .map(|(family, value)| json!({"family":family,"value":value})) + .collect::>() + ); + Ok(result) + } + + pub fn sync(&self, source: &str, cancelled: &(dyn Fn() -> bool + Sync)) -> Result { + let mut state = self.lock()?; + let configs = self.runtime()?; + if !configs + .iter() + .any(|config| !config.ipv4.is_empty() || !config.ipv6.is_empty()) + { + return Err(invalid("add at least one domain before synchronization")); + } + ensure_identity(&mut state, &configs)?; + state.failed.clear(); + state.successful.clear(); + state.cancelled = false; + for (index, config) in configs.iter().enumerate() { + if config.ipv4.is_empty() && config.ipv6.is_empty() { + continue; + } + let outcome = update::update_config(config, cancelled); + let timestamp = now(); + for record in outcome.records { + state.records.insert( + (index, record.domain, record.record_type.to_owned()), + (record.address, timestamp), + ); + } + if !outcome.failures.is_empty() { + state.failed.insert(index); + } + if outcome.cancelled || cancelled() { + state.cancelled = true; + state.activity(source, "Synchronization cancelled", true); + return Err(DashboardError { + status: 409, + code: "cancelled", + message: "Synchronization cancelled.".to_owned(), + }); + } + if outcome.failures.is_empty() { + state.successful.insert(index); + } + } + if !state.failed.is_empty() { + state.activity(source, "Some providers failed to synchronize", true); + return Err(DashboardError { + status: 500, + code: "operation_failed", + message: + "Synchronization failed for one or more providers; inspect provider status." + .to_owned(), + }); + } + state.last_sync = Some(now()); + state.activity(source, "Synchronization completed", false); + self.dashboard_locked(&mut state) + } + + pub fn configure_scheduler( + &self, + action: &str, + scheduler_name: &str, + interval: u16, + ) -> Result { + let _state = self.lock()?; + validation::interval(interval)?; + if !matches!(scheduler_name, "web" | "auto") { + return Err(invalid("only the in-process web scheduler is supported")); + } + let enabled = match action { + "enable" | "install" => Some(true), "disable" | "uninstall" => Some(false), "configure" => None, + "takeover" => return Err(DashboardError { status:501, code:"unsupported", message:"External scheduler inspection and takeover are not supported. Disable external tasks separately before enabling in-process scheduling.".to_owned() }), + _ => return Err(invalid("unsupported scheduler action")), + }; + self.schedule.configure(enabled, interval); + Ok(self.schedule.status()) + } + + pub fn start_scheduler(self: &Arc) -> SchedulerHandle { + scheduler::start(self) + } +} + +fn ensure_identity(state: &mut State, configs: &[Config]) -> Result<()> { + let identities = configs + .iter() + .map(Config::cache_identity) + .collect::>(); + let identity = sha256_hex(serde_json::to_vec(&identities).map_err(operation_error)?); + if state.identity != identity { + let activities = std::mem::take(&mut state.activities); + *state = State { + identity, + activities, + ..State::default() + }; + } + Ok(()) +} diff --git a/rust/src/dashboard/scheduler.rs b/rust/src/dashboard/scheduler.rs new file mode 100644 index 0000000..18f1597 --- /dev/null +++ b/rust/src/dashboard/scheduler.rs @@ -0,0 +1,213 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use super::{DashboardService, now}; + +struct ScheduleState { + enabled: bool, + active: bool, + running: bool, + interval: u16, + next: Option, + last_run: Option, + last_error: Option, +} + +pub(super) struct Schedule { + state: Mutex, + changed: Condvar, + stopping: AtomicBool, +} +impl Schedule { + pub(super) fn new(interval: u16) -> Self { + Self { + state: Mutex::new(ScheduleState { + enabled: true, + active: false, + running: false, + interval, + next: None, + last_run: None, + last_error: None, + }), + changed: Condvar::new(), + stopping: AtomicBool::new(false), + } + } + fn lock(&self) -> MutexGuard<'_, ScheduleState> { + self.state.lock().unwrap_or_else(|error| error.into_inner()) + } + pub(super) fn configure(&self, enabled: Option, interval: u16) { + let mut state = self.lock(); + if let Some(enabled) = enabled { + state.enabled = enabled; + } + state.interval = interval; + rearm(&mut state); + self.changed.notify_all(); + } + pub(super) fn status(&self) -> Value { + let state = self.lock(); + json!({"scheduler":"web", "installed":true,"active":state.active,"enabled":state.enabled,"running":state.running, + "interval":state.interval,"next_run":state.next.map(|next| now()+next.saturating_duration_since(Instant::now()).as_secs_f64()), + "last_run":state.last_run,"last_error":state.last_error,"blocked_reason":null, + "external":{"supported":false,"state":"unknown","message":"External scheduled tasks must be managed separately"}}) + } +} + +fn rearm(state: &mut ScheduleState) { + state.next = (state.enabled && state.active) + .then(|| Instant::now() + Duration::from_secs(u64::from(state.interval) * 60)); +} + +pub struct SchedulerHandle { + schedule: Arc, + thread: Option>, +} +impl SchedulerHandle { + pub fn stop(&mut self) { + if let Some(thread) = self.thread.take() { + { + let _state = self.schedule.lock(); + self.schedule.stopping.store(true, Ordering::Release); + self.schedule.changed.notify_all(); + } + let _ = thread.join(); + } + } +} +impl Drop for SchedulerHandle { + fn drop(&mut self) { + self.stop(); + } +} + +pub(super) fn start(service: &Arc) -> SchedulerHandle { + let schedule = service.schedule.clone(); + let mut state = schedule.lock(); + if state.active { + return SchedulerHandle { + schedule: schedule.clone(), + thread: None, + }; + } + schedule.stopping.store(false, Ordering::Release); + state.active = true; + state.last_error = None; + rearm(&mut state); + let service = service.clone(); + let spawned = thread::Builder::new() + .name("ddns-web-scheduler".to_owned()) + .spawn(move || run(service)); + let thread = match spawned { + Ok(thread) => Some(thread), + Err(_) => { + state.active = false; + state.next = None; + state.last_error = Some("Cannot start scheduler thread".to_owned()); + None + } + }; + drop(state); + SchedulerHandle { schedule, thread } +} + +fn run(service: Arc) { + let schedule = &service.schedule; + loop { + let mut state = schedule.lock(); + loop { + if schedule.stopping.load(Ordering::Acquire) { + state.active = false; + state.running = false; + state.next = None; + return; + } + if !state.enabled { + state.next = None; + state = schedule + .changed + .wait(state) + .unwrap_or_else(|error| error.into_inner()); + continue; + } + if state.next.is_none() { + rearm(&mut state); + } + let delay = state + .next + .expect("enabled scheduler deadline") + .saturating_duration_since(Instant::now()); + if delay.is_zero() { + break; + } + state = schedule + .changed + .wait_timeout(state, delay) + .unwrap_or_else(|error| error.into_inner()) + .0; + } + state.running = true; + state.next = None; + drop(state); + // Never acquire the service mutation lock while holding the scheduler lock. + let result = service.sync("scheduler", &|| schedule.stopping.load(Ordering::Acquire)); + let mut state = schedule.lock(); + state.running = false; + state.last_run = Some(now()); + state.last_error = result.err().map(|error| error.message); + rearm(&mut state); + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::Arc; + use std::thread; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + use serde_json::json; + + use super::super::DashboardService; + + #[test] + fn scheduled_sync_uses_real_updater_and_duplicate_start_does_not_stop_owner() { + let directory = std::env::temp_dir().join(format!( + "ddns-scheduler-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&directory).unwrap(); + let service = + Arc::new(DashboardService::new(Some(directory.join("config.json")), 1).unwrap()); + service.save(json!({"dns":"debug","cache":false,"log":{"level":"CRITICAL"},"ipv4":["scheduled.example.com"],"index4":["shell:echo 192.0.2.7"],"index6":false})).unwrap(); + let mut handle = service.start_scheduler(); + drop(service.start_scheduler()); + { + let mut state = service.schedule.lock(); + assert!(state.active); + state.next = Some(Instant::now()); + service.schedule.changed.notify_all(); + } + let deadline = Instant::now() + Duration::from_secs(5); + while service.schedule.status()["last_run"].is_null() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + let status = service.dashboard().unwrap(); + handle.stop(); + assert_eq!(status["state"], "synced"); + assert_eq!(status["records"][0]["value"], "192.0.2.7"); + assert_eq!(service.schedule.status()["active"], false); + assert!(service.schedule.status()["next_run"].is_null()); + drop(service); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/rust/src/dashboard/storage.rs b/rust/src/dashboard/storage.rs new file mode 100644 index 0000000..ce0d6f8 --- /dev/null +++ b/rust/src/dashboard/storage.rs @@ -0,0 +1,188 @@ +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use super::{Result, operation_error}; + +pub(super) fn target(path: &Path) -> Result { + let mut path = path.to_path_buf(); + for _ in 0..40 { + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let link = fs::read_link(&path).map_err(operation_error)?; + path = if link.is_absolute() { + link + } else { + path.parent().unwrap_or(Path::new(".")).join(link) + }; + } + Ok(_) => return fs::canonicalize(path).map_err(operation_error), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path), + Err(error) => return Err(operation_error(error)), + } + } + Err(operation_error("too many symbolic links")) +} + +pub(super) fn backup(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(".bak"); + PathBuf::from(name) +} + +struct Temporary(PathBuf); +impl Temporary { + fn new(directory: &Path, content: &[u8]) -> io::Result { + for _ in 0..16 { + let mut random = [0u8; 16]; + getrandom::fill(&mut random) + .map_err(|_| io::Error::other("secure entropy unavailable"))?; + let name = random + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let path = directory.join(format!(".ddns-{name}.tmp")); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(mut file) => { + let temporary = Self(path); + file.write_all(content)?; + file.sync_all()?; + return Ok(temporary); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to allocate a secure temporary file", + )) + } + fn replace(&self, path: &Path) -> io::Result<()> { + fs::rename(&self.0, path) + } +} +impl Drop for Temporary { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +pub(super) fn write(path: &Path, content: &[u8]) -> Result<()> { + let path = target(path)?; + let directory = path.parent().unwrap_or(Path::new(".")); + fs::create_dir_all(directory).map_err(operation_error)?; + let temporary = Temporary::new(directory, content).map_err(operation_error)?; + match fs::read(&path) { + Ok(previous) => { + let saved = Temporary::new(directory, &previous).map_err(operation_error)?; + let backup_path = backup(&path); + let old_backup = match fs::read(&backup_path) { + Ok(content) => Some(Temporary::new(directory, &content).map_err(operation_error)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(operation_error(error)), + }; + commit_pair(&path, &temporary, &saved, old_backup.as_ref()).map_err(operation_error)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + temporary.replace(&path).map_err(operation_error)? + } + Err(error) => return Err(operation_error(error)), + } + sync_directory(directory); + Ok(()) +} + +fn commit_pair( + path: &Path, + replacement: &Temporary, + saved: &Temporary, + old_backup: Option<&Temporary>, +) -> io::Result<()> { + let backup_path = backup(path); + saved.replace(&backup_path)?; + if let Err(error) = replacement.replace(path) { + if let Some(old_backup) = old_backup { + old_backup.replace(&backup_path)?; + } else { + fs::remove_file(&backup_path)?; + } + return Err(error); + } + Ok(()) +} + +pub(super) fn restore(path: &Path, restored: &[u8]) -> Result<()> { + let path = target(path)?; + let directory = path.parent().unwrap_or(Path::new(".")); + let previous = match fs::read(&path) { + Ok(content) => Some(Temporary::new(directory, &content).map_err(operation_error)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(operation_error(error)), + }; + let replacement = Temporary::new(directory, restored).map_err(operation_error)?; + replacement.replace(&path).map_err(operation_error)?; + if let Some(previous) = previous + && let Err(error) = previous.replace(&backup(&path)) + { + // The backup commit failed; put the current file back before reporting failure. + previous.replace(&path).map_err(operation_error)?; + return Err(operation_error(error)); + } + sync_directory(directory); + Ok(()) +} + +fn sync_directory(directory: &Path) { + #[cfg(unix)] + if let Ok(file) = fs::File::open(directory) { + let _ = file.sync_all(); + } + #[cfg(not(unix))] + let _ = directory; +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::{Temporary, backup, commit_pair}; + + #[test] + fn failed_final_replacement_restores_original_backup() { + let directory = std::env::temp_dir().join(format!( + "ddns-backup-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&directory).unwrap(); + let path = directory.join("config.json"); + fs::create_dir(&path).unwrap(); + fs::write(path.join("occupied"), b"keep").unwrap(); + for previous in [Some(b"original backup".as_slice()), None] { + let backup_path = backup(&path); + let _ = fs::remove_file(&backup_path); + if let Some(content) = previous { + fs::write(&backup_path, content).unwrap(); + } + let old_backup = previous.map(|content| Temporary::new(&directory, content).unwrap()); + let saved = Temporary::new(&directory, b"current").unwrap(); + let replacement = Temporary::new(&directory, b"replacement").unwrap(); + assert!(commit_pair(&path, &replacement, &saved, old_backup.as_ref()).is_err()); + assert_eq!(fs::read(&backup_path).ok().as_deref(), previous); + assert_eq!(fs::read(path.join("occupied")).unwrap(), b"keep"); + } + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/rust/src/dashboard/validation.rs b/rust/src/dashboard/validation.rs new file mode 100644 index 0000000..40a2152 --- /dev/null +++ b/rust/src/dashboard/validation.rs @@ -0,0 +1,474 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::{Map, Value, json}; + +use crate::config; +use crate::provider::ProviderId; + +use super::{DashboardError, Result}; + +pub(super) static MODEL: LazyLock = LazyLock::new(|| { + serde_json::from_str(include_str!("../../../ddns/config/field-model.json")) + .expect("canonical field model must be valid JSON") +}); +static DOMAIN: LazyLock = LazyLock::new(|| { + Regex::new( + MODEL["rules"]["domainPattern"] + .as_str() + .expect("domain pattern"), + ) + .expect("valid domain pattern") +}); +static PROXY: LazyLock = LazyLock::new(|| { + Regex::new( + MODEL["rules"]["proxyPattern"] + .as_str() + .expect("proxy pattern"), + ) + .expect("valid proxy pattern") +}); + +const GLOBAL: &[&str] = &[ + "ssl", + "proxy", + "cache", + "cache_max_age", + "interval", + "http", + "log", +]; +const FIELDS: &[&str] = &[ + "id", + "token", + "endpoint", + "ipv4", + "ipv6", + "index4", + "index6", + "ttl", + "line", + "proxy", + "cache", + "cache_max_age", + "ssl", + "extra", + "log", +]; +const METADATA: &[&str] = &[ + "$schema", + "command", + "config", + "debug", + "dns", + "provider", + "interval", + "http", + "http_host", + "http_port", + "http_token", + "http_origins", +]; + +pub(super) fn interval(value: u16) -> Result { + if (1..=1440).contains(&value) { + Ok(value) + } else { + Err(invalid("interval must be between 1 and 1440 minutes")) + } +} + +pub(super) fn invalid(message: impl Into) -> DashboardError { + DashboardError { + status: 400, + code: "invalid_config", + message: message.into(), + } +} + +fn structured(mut object: Map) -> Map { + for (prefix, keys) in [ + ("log", &["level", "file", "format", "datefmt"][..]), + ("http", &["host", "port", "token", "origins"][..]), + ] { + for key in keys { + if let Some(value) = object.remove(&format!("{prefix}_{key}")) { + let nested = object.entry(prefix).or_insert_with(|| json!({})); + if let Some(nested) = nested.as_object_mut() { + nested.insert((*key).to_owned(), value); + } + } + } + } + object +} + +fn legacy_provider( + object: Map, + fallback: Option<&str>, + exclude: &[&str], +) -> Result { + let mut object = structured(object); + let name = object + .remove("provider") + .or_else(|| object.remove("dns")) + .or_else(|| fallback.map(|name| json!(name))) + .unwrap_or(json!("")); + let mut result = Map::from_iter([("provider".to_owned(), name)]); + let mut extra = match object.remove("extra") { + None => Map::new(), + Some(Value::Object(extra)) => extra, + Some(_) => return Err(invalid("extra must be an object")), + }; + for (key, value) in object { + if exclude.contains(&key.as_str()) || METADATA.contains(&key.as_str()) { + continue; + } + if FIELDS.contains(&key.as_str()) { + result.insert(key, value); + } else { + extra.insert(key.strip_prefix("extra_").unwrap_or(&key).to_owned(), value); + } + } + if !extra.is_empty() { + result.insert("extra".to_owned(), Value::Object(extra)); + } + Ok(Value::Object(result)) +} + +fn normalize(document: Value, fallback: Option<&str>) -> Result { + match document { + Value::Object(object) if object.contains_key("providers") => Ok(Value::Object(object)), + Value::Object(object) => { + let object = structured(object); + let mut result = Map::new(); + for key in GLOBAL { + if let Some(value) = object.get(*key) { + result.insert((*key).to_owned(), value.clone()); + } + } + let has_provider = object + .get("dns") + .or_else(|| object.get("provider")) + .is_some() + || fallback.is_some(); + let providers = if has_provider { + vec![legacy_provider(object, fallback, GLOBAL)?] + } else if object.keys().any(|key| key != "$schema") { + return Err(invalid( + "single-provider configuration requires dns or DDNS_DNS", + )); + } else { + Vec::new() + }; + result.insert("providers".to_owned(), json!(providers)); + Ok(Value::Object(result)) + } + Value::Array(items) => { + let mut providers = Vec::new(); + for item in items { + let object = item + .as_object() + .ok_or_else(|| invalid("legacy provider entries must be objects"))?; + providers.push(legacy_provider(object.clone(), fallback, &[])?); + } + if providers.is_empty() + && let Some(name) = fallback + { + providers.push(json!({"provider":name})); + } + Ok(json!({"providers":providers})) + } + _ => Err(invalid("configuration root must be an object or array")), + } +} + +pub(super) fn validate(document: Value, environment: &BTreeMap) -> Result { + let fallback = environment + .get("dns") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()); + let mut document = normalize(document, fallback)?; + let object = document.as_object_mut().expect("normalized object"); + for key in MODEL["rules"]["legacyProviderKeys"] + .as_array() + .expect("legacy keys") + { + if object.contains_key(key.as_str().expect("key")) { + return Err(invalid( + "providers cannot be combined with legacy provider fields", + )); + } + } + inherited(object)?; + if let Some(value) = object.get("interval") { + let value = value + .as_u64() + .and_then(|value| u16::try_from(value).ok()) + .ok_or_else(|| invalid("interval must be an integer"))?; + interval(value)?; + } + if let Some(value) = object.get_mut("http") { + validate_http(value)?; + } + let providers = object + .get_mut("providers") + .and_then(Value::as_array_mut) + .ok_or_else(|| invalid("providers must be an array"))?; + if providers.len() > MODEL["limits"]["providers"].as_u64().unwrap_or(128) as usize { + return Err(invalid("too many providers")); + } + for provider in providers { + let provider = provider + .as_object_mut() + .ok_or_else(|| invalid("provider entries must be objects"))?; + if [ + "dns", + "interval", + "http", + "http_host", + "http_port", + "http_token", + "http_origins", + ] + .iter() + .any(|key| provider.contains_key(*key)) + { + return Err(invalid( + "provider entries cannot contain dns, interval, or HTTP settings", + )); + } + let name = provider + .get("provider") + .and_then(Value::as_str) + .ok_or_else(|| invalid("provider must be a string"))? + .trim() + .to_ascii_lowercase(); + name.parse::() + .map_err(|_| invalid("unsupported DNS provider"))?; + provider.insert("provider".to_owned(), json!(name)); + inherited(provider)?; + } + object.insert("$schema".to_owned(), MODEL["schema"]["url"].clone()); + let mut runtime_document = document.clone(); + // Even an empty providers list must validate inherited runtime settings. + if runtime_document["providers"] + .as_array() + .is_some_and(Vec::is_empty) + { + runtime_document["providers"] = json!([{"provider":"debug"}]); + } + let configs = config::from_document(runtime_document, environment) + .map_err(|_| invalid("configuration or environment contains invalid runtime settings"))?; + for config in configs { + if config + .ipv4 + .iter() + .chain(&config.ipv6) + .any(|domain| domain.len() > 253 || !DOMAIN.is_match(domain)) + { + return Err(invalid("runtime configuration contains an invalid domain")); + } + } + Ok(document) +} + +fn inherited(object: &mut Map) -> Result<()> { + if ["domain", "value", "record_type"] + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(invalid("configuration contains reserved record fields")); + } + for key in ["id", "endpoint", "line"] { + if let Some(value) = object.get(key) + && !(value.is_null() || value.is_string()) + { + return Err(invalid(format!("{key} must be a string or null"))); + } + } + if let Some(value) = object.get("token") + && !(value.is_string() || value.is_object()) + { + return Err(invalid( + "token must be a string (or a callback body object)", + )); + } + for key in ["ipv4", "ipv6"] { + if let Some(value) = object.get_mut(key) { + let values = strings(value, false)? + .into_iter() + .map(|domain| domain.trim().to_ascii_lowercase()) + .collect::>(); + if values + .iter() + .any(|domain| domain.len() > 253 || !DOMAIN.is_match(domain)) + { + return Err(invalid(format!("{key} contains an invalid domain"))); + } + unique(&values)?; + *value = json!(values); + } + } + for key in ["index4", "index6"] { + if let Some(value) = object.get_mut(key) { + if value == &Value::Bool(false) { + continue; + } + let values = strings(value, true)? + .into_iter() + .map(|source| source.trim().to_owned()) + .collect::>(); + for source in &values { + validate_source(source)?; + } + unique(&values)?; + *value = if values.is_empty() { + json!(false) + } else { + json!(values) + }; + } + } + if let Some(value) = object.get_mut("proxy") { + let proxies = strings(value, false)? + .into_iter() + .map(|proxy| proxy.trim().to_owned()) + .collect::>(); + if proxies.iter().any(|proxy| !PROXY.is_match(proxy)) { + return Err(invalid("proxy contains an invalid value")); + } + unique(&proxies)?; + *value = json!(proxies); + } + for key in ["ttl", "cache_max_age"] { + if let Some(value) = object.get_mut(key) + && !value.is_null() + { + let parsed = value + .as_u64() + .or_else(|| value.as_str().and_then(|v| v.parse::().ok())); + *value = json!( + parsed.ok_or_else(|| invalid(format!("{key} must be a non-negative integer")))? + ); + } + } + if let Some(extra) = object.get("extra") { + let extra = extra + .as_object() + .ok_or_else(|| invalid("extra must be an object"))?; + if MODEL["rules"]["reservedExtraKeys"] + .as_array() + .expect("reserved keys") + .iter() + .any(|key| extra.contains_key(key.as_str().expect("key"))) + { + return Err(invalid("extra contains reserved record fields")); + } + } + for key in MODEL["rules"]["reservedExtraKeys"] + .as_array() + .expect("reserved keys") + { + if object.contains_key(&format!("extra_{}", key.as_str().expect("key"))) { + return Err(invalid("extra contains reserved record fields")); + } + } + if let Some(log) = object.get("log") { + let log = log + .as_object() + .ok_or_else(|| invalid("log must be an object"))?; + if log + .keys() + .any(|key| !["level", "file", "format", "datefmt"].contains(&key.as_str())) + { + return Err(invalid("unknown log setting")); + } + for value in log.values() { + if !value.is_null() && !value.is_string() { + return Err(invalid("log settings must be strings or null")); + } + } + } + Ok(()) +} + +fn strings(value: &Value, sources: bool) -> Result> { + if value.is_object() || value.is_boolean() || (!sources && value.is_number()) { + return Err(invalid("expected a string or array")); + } + if let Value::Array(values) = value + && values + .iter() + .any(|v| !v.is_string() && !(sources && v.as_u64().is_some())) + { + return Err(invalid("invalid array element")); + } + config::value_list(Some(value), sources).map_err(|_| invalid("invalid list value")) +} + +fn unique(values: &[String]) -> Result<()> { + if values + .iter() + .collect::>() + .len() + != values.len() + { + return Err(invalid("duplicate list entries are not allowed")); + } + Ok(()) +} + +fn validate_source(source: &str) -> Result<()> { + if source.parse::().is_ok() + || MODEL["rules"]["addressSourceNames"] + .as_array() + .expect("source names") + .iter() + .any(|name| name.as_str() == Some(source)) + { + return Ok(()); + } + for prefix in MODEL["rules"]["addressSourcePrefixes"] + .as_array() + .expect("source prefixes") + { + let prefix = prefix.as_str().expect("prefix"); + if let Some(argument) = source.strip_prefix(prefix) { + if argument.trim().is_empty() { + return Err(invalid("address source argument is empty")); + } + if prefix == "url:" { + let uri = argument + .parse::() + .map_err(|_| invalid("invalid IP URL"))?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri.host().is_none_or(str::is_empty) + { + return Err(invalid("IP URL must have an HTTP(S) host")); + } + } + if prefix == "regex:" { + Regex::new(argument).map_err(|_| invalid("invalid source regular expression"))?; + } + return Ok(()); + } + } + Err(invalid("unsupported address source")) +} + +fn validate_http(value: &mut Value) -> Result<()> { + if let Some(origins) = value.get("origins") + && !origins.is_array() + { + return Err(invalid("HTTP origins must be an array")); + } + let settings = crate::http_settings::HttpSettings::from_value(value, false) + .map_err(|_| invalid("invalid HTTP settings"))?; + let normalized = json!({"host":settings.host, "port":settings.port, "token":settings.token, "origins":settings.origins}); + for (key, value) in value.as_object_mut().expect("validated HTTP object") { + *value = normalized[key].clone(); + } + Ok(()) +} diff --git a/rust/src/http_server.rs b/rust/src/http_server.rs new file mode 100644 index 0000000..2ae7ee3 --- /dev/null +++ b/rust/src/http_server.rs @@ -0,0 +1,653 @@ +use std::cell::Cell; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use crate::dashboard::{DashboardError, DashboardService}; +use crate::error::{Error, Result}; +use crate::http_settings::{ + HttpSettings, host_header_is_loopback, normalize_origin, token_matches, +}; + +pub(crate) const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; +const MAX_HEADER_SIZE: usize = 64 * 1024; +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_CONNECTIONS: usize = 64; + +pub(crate) struct Request { + pub method: String, + pub path: String, + headers: Vec<(String, String)>, + body_read: Cell, +} + +impl Request { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + + pub fn header_count(&self, name: &str) -> usize { + self.headers + .iter() + .filter(|(key, _)| key.eq_ignore_ascii_case(name)) + .count() + } + + pub fn token(&self, allow_dashboard_header: bool) -> Option<&str> { + let bearer = self.header("authorization").and_then(|value| { + let (scheme, token) = value.split_once(char::is_whitespace)?; + scheme.eq_ignore_ascii_case("bearer").then(|| token.trim()) + }); + bearer.or_else(|| { + allow_dashboard_header + .then(|| self.header("x-ddns-token")) + .flatten() + }) + } + + pub fn origin(&self) -> Option { + self.header("origin") + .and_then(|origin| normalize_origin(origin).ok()) + } + + pub fn origin_allowed(&self, settings: &HttpSettings, cors: bool) -> bool { + if self.header("origin").is_none() { + return true; + } + let Some(origin) = self.origin() else { + return false; + }; + let direct = self + .header("host") + .and_then(|host| normalize_origin(&format!("http://{host}")).ok()); + direct.as_ref() == Some(&origin) || (cors && settings.origins.contains(&origin)) + } + + fn read(reader: &mut impl BufRead) -> std::result::Result { + let mut remaining = MAX_HEADER_SIZE; + let line = read_header_line(reader, &mut remaining)?; + let mut parts = line.split(' '); + let method = parts.next().unwrap_or_default(); + let target = parts.next().unwrap_or_default(); + let version = parts.next().unwrap_or_default(); + if parts.next().is_some() + || !["HTTP/1.0", "HTTP/1.1"].contains(&version) + || method.is_empty() + || !method.bytes().all(|byte| byte.is_ascii_uppercase()) + || !target.starts_with('/') + || target.starts_with("//") + || target.contains('#') + || target + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(Response::error( + 400, + "invalid_request", + "Invalid HTTP request line.", + )); + } + let method = method.to_owned(); + let path = target.split('?').next().unwrap_or(target).to_owned(); + let mut headers = Vec::new(); + loop { + let line = read_header_line(reader, &mut remaining)?; + if line.is_empty() { + break; + } + let Some((name, value)) = line.split_once(':') else { + return Err(Response::error( + 400, + "invalid_request", + "Invalid HTTP header.", + )); + }; + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)) + || value + .bytes() + .any(|byte| (byte < 0x20 && byte != b'\t') || byte == 0x7f) + { + return Err(Response::error( + 400, + "invalid_request", + "Invalid HTTP header.", + )); + } + headers.push(( + name.to_ascii_lowercase(), + value.trim_matches([' ', '\t']).to_owned(), + )); + if headers.len() > 100 { + return Err(Response::error( + 431, + "headers_too_large", + "Too many HTTP headers.", + )); + } + } + let request = Self { + method, + path, + headers, + body_read: Cell::new(false), + }; + for name in [ + "host", + "content-length", + "authorization", + "x-ddns-token", + "origin", + "content-type", + "accept", + ] { + if request.header_count(name) > 1 { + return Err(Response::error( + 400, + "invalid_request", + "Repeated HTTP header.", + )); + } + } + if request.header("transfer-encoding").is_some() { + return Err(Response::error( + 400, + "invalid_request", + "Transfer-Encoding is not supported.", + )); + } + if request.header("expect").is_some() { + return Err(Response::error( + 417, + "expectation_failed", + "Expect is not supported.", + )); + } + Ok(request) + } + + pub fn read_json( + &self, + reader: &mut impl Read, + mcp: bool, + ) -> std::result::Result { + if !self + .header("content-type") + .unwrap_or_default() + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + { + return Err(Response::error( + 415, + "unsupported_media_type", + "Content-Type must be application/json.", + )); + } + let length = self.header("content-length").ok_or_else(|| { + Response::error(411, "length_required", "Content-Length is required.") + })?; + if length.is_empty() || !length.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(Response::error( + 400, + "invalid_length", + "Content-Length is invalid.", + )); + } + let length = length + .parse::() + .ok() + .filter(|length| *length <= MAX_BODY_SIZE) + .ok_or_else(|| { + Response::error(413, "payload_too_large", "Request body is too large.") + })?; + let mut content = vec![0; length]; + self.body_read.set(true); + reader + .read_exact(&mut content) + .map_err(|_| Response::error(400, "invalid_body", "Request body is incomplete."))?; + let value: Value = serde_json::from_slice(&content).map_err(|_| { + if mcp { + Response::json( + 400, + crate::mcp::error_response(Value::Null, -32700, "Parse error"), + ) + } else { + Response::error( + 400, + "invalid_json", + "Request body must contain valid UTF-8 JSON.", + ) + } + })?; + if !value.is_object() { + return Err(if mcp { + Response::json( + 400, + crate::mcp::error_response(Value::Null, -32600, "Invalid Request"), + ) + } else { + Response::error(400, "invalid_json", "Request body must be a JSON object.") + }); + } + Ok(value) + } +} + +fn read_header_line( + reader: &mut impl BufRead, + remaining: &mut usize, +) -> std::result::Result { + let mut line = Vec::new(); + reader + .take((*remaining + 1) as u64) + .read_until(b'\n', &mut line) + .map_err(|_| Response::error(408, "request_timeout", "HTTP header read failed."))?; + if line.len() > *remaining { + return Err(Response::error( + 431, + "headers_too_large", + "HTTP headers are too large.", + )); + } + *remaining -= line.len(); + if !line.ends_with(b"\r\n") { + return Err(Response::error( + 400, + "invalid_request", + "HTTP headers require CRLF framing.", + )); + } + line.truncate(line.len() - 2); + String::from_utf8(line) + .map_err(|_| Response::error(400, "invalid_request", "Invalid HTTP header encoding.")) +} + +pub(crate) struct Response { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl Response { + pub fn empty(status: u16) -> Self { + Self { + status, + headers: Vec::new(), + body: Vec::new(), + } + } + pub fn bytes(status: u16, body: Vec, content_type: &str) -> Self { + Self { + status, + headers: vec![("Content-Type".to_owned(), content_type.to_owned())], + body, + } + } + pub fn json(status: u16, value: Value) -> Self { + Self::bytes( + status, + serde_json::to_vec(&value).expect("JSON Value serialization"), + "application/json; charset=utf-8", + ) + } + pub fn error(status: u16, code: &str, message: &str) -> Self { + Self::json(status, json!({"error":{"code":code,"message":message}})) + } + pub fn header(mut self, name: &str, value: &str) -> Self { + self.headers.push((name.to_owned(), value.to_owned())); + self + } + fn write(&self, stream: &mut impl Write, head: bool) -> std::io::Result<()> { + let reason = ureq::http::StatusCode::from_u16(self.status) + .ok() + .and_then(|status| status.canonical_reason()) + .unwrap_or("Response"); + write!( + stream, + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nX-Frame-Options: DENY\r\nCross-Origin-Opener-Policy: same-origin\r\nCross-Origin-Resource-Policy: same-origin\r\nPermissions-Policy: camera=(), geolocation=(), microphone=()\r\nContent-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; object-src 'none'\r\n", + self.status, + reason, + self.body.len() + )?; + for (name, value) in &self.headers { + write!(stream, "{name}: {value}\r\n")?; + } + stream.write_all(b"\r\n")?; + if !head { + stream.write_all(&self.body)?; + } + stream.flush() + } +} + +impl From for Response { + fn from(error: DashboardError) -> Self { + Self::error(error.status, error.code, &error.message) + } +} + +struct ServerState { + service: Arc, + settings: HttpSettings, + web: bool, + launch: Mutex>, +} + +pub struct HttpServer { + listener: TcpListener, + state: Arc, +} + +impl HttpServer { + pub fn bind(service: Arc, settings: HttpSettings, web: bool) -> Result { + // Revalidate public settings so direct callers cannot bypass bind authentication. + let settings = HttpSettings::from_value( + &json!({"host":settings.host,"port":settings.port,"token":settings.token,"origins":settings.origins}), + true, + )?; + let listener = TcpListener::bind((settings.host.as_str(), settings.port))?; + listener.set_nonblocking(true)?; + Ok(Self { + listener, + state: Arc::new(ServerState { + service, + settings, + web, + launch: Mutex::new(None), + }), + }) + } + + pub fn local_addr(&self) -> Result { + Ok(self.listener.local_addr()?) + } + + pub fn launch_url(&self) -> Result { + let address = self.local_addr()?; + let host = match address.ip() { + std::net::IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_owned(), + std::net::IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_owned(), + std::net::IpAddr::V6(ip) => format!("[{ip}]"), + ip => ip.to_string(), + }; + let origin = format!("http://{host}:{}", address.port()); + if !self.state.web { + return Ok(format!("{origin}/mcp")); + } + if self.state.settings.token.is_none() { + return Ok(format!("{origin}/#view=overview")); + } + let mut bytes = [0; 24]; + getrandom::fill(&mut bytes) + .map_err(|_| Error::Http("Unable to create browser launch token".to_owned()))?; + let token = bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + *self + .state + .launch + .lock() + .map_err(|_| Error::Http("Browser launch state unavailable".to_owned()))? = + Some((token.clone(), Instant::now())); + Ok(format!("{origin}/launch/{token}")) + } + + pub fn serve(&self, stop: &AtomicBool) -> Result<()> { + let active = Arc::new(AtomicUsize::new(0)); + let mut workers: Vec> = Vec::new(); + while !stop.load(Ordering::Acquire) { + match self.listener.accept() { + Ok((mut stream, _)) => { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(CONNECTION_TIMEOUT))?; + stream.set_write_timeout(Some(CONNECTION_TIMEOUT))?; + if active.load(Ordering::Acquire) >= MAX_CONNECTIONS { + let _ = Response::error(503, "busy", "Too many active connections.") + .write(&mut stream, false); + continue; + } + active.fetch_add(1, Ordering::AcqRel); + let state = Arc::clone(&self.state); + let active = Arc::clone(&active); + workers.push(thread::spawn(move || { + struct ActiveConnection(Arc); + impl Drop for ActiveConnection { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } + } + let _active = ActiveConnection(active); + handle_connection(stream, &state); + })); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)) + } + Err(error) => return Err(error.into()), + } + let mut index = 0; + while index < workers.len() { + if workers[index].is_finished() { + let _ = workers.swap_remove(index).join(); + } else { + index += 1; + } + } + } + for worker in workers { + let _ = worker.join(); + } + Ok(()) + } +} + +fn handle_connection(stream: TcpStream, state: &ServerState) { + let mut reader = BufReader::new(stream); + let (response, head) = match Request::read(&mut reader) { + Ok(request) => { + let head = request.method == "HEAD"; + let response = route(&request, &mut reader, state); + discard_unread_body(&request, &mut reader); + (response, head) + } + Err(response) => (response, false), + }; + let _ = response.write(reader.get_mut(), head); +} + +fn discard_unread_body(request: &Request, reader: &mut BufReader) { + if request.body_read.get() { + return; + } + let Some(mut remaining) = request + .header("content-length") + .and_then(|length| length.parse::().ok()) + .filter(|length| *length <= MAX_BODY_SIZE) + else { + return; + }; + // Unread request bytes can reset early rejection responses on Windows. + // Bound the entire drain, not each read, so authentication never waits for an unsent body. + let deadline = Instant::now() + Duration::from_millis(50); + let mut buffer = [0; 4096]; + while remaining > 0 { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() || reader.get_mut().set_read_timeout(Some(timeout)).is_err() { + break; + } + let count = remaining.min(buffer.len()); + match reader.read(&mut buffer[..count]) { + Ok(0) | Err(_) => break, + Ok(count) => remaining -= count, + } + } +} + +fn route(request: &Request, reader: &mut impl Read, state: &ServerState) -> Response { + if request.path == "/mcp" { + return crate::mcp_http::handle( + request, + reader, + &state.settings, + Arc::clone(&state.service), + ); + } + if !state.web { + return Response::error(404, "not_found", "Resource not found."); + } + if state.settings.token.is_none() + && !host_header_is_loopback(request.header("host").unwrap_or_default()) + { + return Response::error( + 421, + "invalid_host", + "Unauthenticated dashboard only accepts local hosts.", + ); + } + if request.method == "GET" || request.method == "HEAD" { + if let Some(token) = request.path.strip_prefix("/launch/") { + let consumed = if request.method == "HEAD" { + false + } else { + state + .launch + .lock() + .map(|mut launch| { + let valid = launch.as_ref().is_some_and(|(expected, issued)| { + issued.elapsed() <= Duration::from_secs(60) + && token_matches(Some(token), Some(expected)) + }); + if valid { + *launch = None; + } + valid + }) + .unwrap_or(false) + }; + if !consumed { + return Response::error( + 403, + "invalid_launch_token", + "Dashboard launch token is invalid.", + ); + } + let token = state.settings.token.as_deref().unwrap_or_default(); + return Response::empty(302).header( + "Location", + &format!( + "/#token={}&view=overview", + crate::http::percent_encode(token) + ), + ); + } + if let Some((content, content_type)) = asset(&request.path) { + return Response::bytes(200, content.to_vec(), content_type); + } + } + if !request.path.starts_with("/api/") { + return Response::error(404, "not_found", "Resource not found."); + } + if !token_matches(request.token(true), state.settings.token.as_deref()) { + return Response::error(403, "invalid_token", "Dashboard request token is invalid."); + } + if !request.origin_allowed(&state.settings, false) { + return Response::error( + 403, + "invalid_origin", + "Dashboard request origin is not allowed.", + ); + } + let result = match (request.method.as_str(), request.path.as_str()) { + ("GET" | "HEAD", "/api/dashboard") => state.service.dashboard(), + ("GET" | "HEAD", "/api/config") => state.service.config_state(), + ("POST" | "PUT", _) => { + let payload = match request.read_json(reader, false) { + Ok(payload) => payload, + Err(response) => return response, + }; + match (request.method.as_str(), request.path.as_str()) { + ("PUT", "/api/config") => state + .service + .save(payload.get("config").cloned().unwrap_or(Value::Null)), + ("POST", "/api/config/validate") => state + .service + .validate(payload.get("config").cloned().unwrap_or(Value::Null)) + .map(|config| json!({"config":config})), + ("POST", "/api/config/restore") => state.service.restore_backup(), + ("POST", "/api/sync") => state.service.sync("Web", &|| false), + ("POST", "/api/scheduler") => { + let interval = match payload.get("interval") { + None => 5, + Some(value) => match value + .as_u64() + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| (1..=1440).contains(value)) + { + Some(value) => value, + None => { + return Response::error( + 400, + "invalid_config", + "Scheduler interval must be between 1 and 1440 minutes.", + ); + } + }, + }; + state + .service + .configure_scheduler( + payload + .get("action") + .and_then(Value::as_str) + .unwrap_or_default(), + payload + .get("scheduler") + .and_then(Value::as_str) + .unwrap_or("auto"), + interval, + ) + .map(|scheduler| json!({"scheduler":scheduler})) + } + _ => return Response::error(404, "not_found", "Resource not found."), + } + } + _ => return Response::error(405, "method_not_allowed", "Method not allowed."), + }; + match result { + Ok(value) => Response::json(200, value), + Err(error) => error.into(), + } +} + +fn asset(path: &str) -> Option<(&'static [u8], &'static str)> { + match path { + "/" | "/index.html" | "/dashboard" | "/dashboard/" => Some(( + include_bytes!("../../web/index.html"), + "text/html; charset=utf-8", + )), + "/assets/dashboard.js" => Some(( + include_bytes!("../../web/dashboard.js"), + "application/javascript; charset=utf-8", + )), + "/assets/dashboard.css" => Some(( + include_bytes!("../../web/dashboard.css"), + "text/css; charset=utf-8", + )), + "/assets/ddns.svg" => Some((include_bytes!("../../web/ddns.svg"), "image/svg+xml")), + _ => None, + } +} diff --git a/rust/src/http_settings.rs b/rust/src/http_settings.rs new file mode 100644 index 0000000..ffd8ec1 --- /dev/null +++ b/rust/src/http_settings.rs @@ -0,0 +1,320 @@ +use std::collections::BTreeMap; +use std::net::IpAddr; + +use serde_json::{Map, Value}; +use ureq::http::uri::Authority; + +use crate::error::{Error, Result}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpSettings { + pub host: String, + pub port: u16, + pub token: Option, + pub origins: Vec, +} + +impl HttpSettings { + pub fn resolve( + cli: &BTreeMap, + document: &Value, + environment: &BTreeMap, + ) -> Result { + let mut merged = Map::new(); + for key in ["host", "port", "token", "origins"] { + if let Some(value) = environment.get(&format!("http_{key}")) { + merged.insert(key.to_owned(), value.clone()); + } + } + if let Some(http) = document.get("http") { + let http = http + .as_object() + .ok_or_else(|| invalid("http must be an object"))?; + if http.get("origins").is_some_and(|value| !value.is_array()) { + return Err(invalid("HTTP origins must be an array")); + } + merged.extend(http.clone()); + } + merged.extend(cli.iter().map(|(key, value)| (key.clone(), value.clone()))); + Self::from_value(&Value::Object(merged), true) + } + + pub fn from_value(value: &Value, enforce_bind_auth: bool) -> Result { + let value = value + .as_object() + .ok_or_else(|| invalid("http must be an object"))?; + if value + .keys() + .any(|key| !["host", "port", "token", "origins"].contains(&key.as_str())) + { + return Err(invalid("Unsupported HTTP setting")); + } + let raw_host = match value.get("host") { + None => "127.0.0.1", + Some(value) => value + .as_str() + .ok_or_else(|| invalid("HTTP host must be a string"))?, + }; + let raw_host = raw_host.trim(); + let host = if raw_host.starts_with('[') && raw_host.ends_with(']') { + &raw_host[1..raw_host.len() - 1] + } else { + raw_host + }; + if host.is_empty() + || !host.is_ascii() + || host + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) + || host.contains(['/', '\\', '[', ']', '@', '?', '#']) + || (host.contains(':') && host.parse::().is_err()) + { + return Err(invalid("HTTP host must be an unambiguous bind address")); + } + let port = match value.get("port") { + None => 9876, + Some(Value::String(port)) => port + .parse::() + .map_err(|_| invalid("HTTP port must be an integer from 0 to 65535"))?, + Some(value) => value + .as_u64() + .and_then(|port| u16::try_from(port).ok()) + .ok_or_else(|| invalid("HTTP port must be an integer from 0 to 65535"))?, + }; + let token = match value.get("token") { + None | Some(Value::Null) => None, + Some(Value::String(token)) + if !token.is_empty() && token.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) => + { + Some(token.clone()) + } + _ => { + return Err(invalid( + "HTTP token must contain non-empty visible ASCII characters or be null", + )); + } + }; + if enforce_bind_auth && !is_loopback_host(host) && token.is_none() { + return Err(invalid( + "A non-empty HTTP token is required for non-loopback listeners", + )); + } + let entries = match value.get("origins") { + None => Vec::new(), + Some(Value::String(value)) => value + .split([',', ';']) + .filter(|item| !item.trim().is_empty()) + .map(|item| Value::String(item.trim().to_owned())) + .collect(), + Some(Value::Array(values)) => values.clone(), + _ => return Err(invalid("HTTP origins must be an array")), + }; + let mut origins = Vec::new(); + for entry in entries { + let origin = normalize_origin( + entry + .as_str() + .ok_or_else(|| invalid("HTTP origins must contain strings"))?, + )?; + if !origins.contains(&origin) { + origins.push(origin); + } + } + Ok(Self { + host: host.to_owned(), + port, + token, + origins, + }) + } +} + +fn invalid(message: &str) -> Error { + Error::Config(message.to_owned()) +} + +pub fn is_loopback_host(host: &str) -> bool { + let host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +fn authority(value: &str) -> Result { + if value.is_empty() + || !value.is_ascii() + || value.contains(['@', '/', '\\', '?', '#']) + || value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(invalid("Invalid HTTP authority")); + } + let authority = value + .parse::() + .map_err(|_| invalid("Invalid HTTP authority"))?; + let host = authority.host(); + let suffix = value + .strip_prefix(host) + .ok_or_else(|| invalid("Invalid HTTP authority"))?; + if host.is_empty() + || (!suffix.is_empty() + && !suffix.strip_prefix(':').is_some_and(|port| { + !port.is_empty() + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port.parse::().is_ok() + })) + { + return Err(invalid("Invalid HTTP authority")); + } + Ok(authority) +} + +pub fn host_header_is_loopback(host: &str) -> bool { + authority(host).is_ok_and(|authority| is_loopback_host(authority.host())) +} + +pub fn normalize_origin(origin: &str) -> Result { + let origin = origin.trim(); + if origin + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) + || origin.contains(['?', '#', '\\']) + { + return Err(invalid("HTTP origin must be an exact http(s) origin")); + } + let (scheme, host) = origin + .split_once("://") + .ok_or_else(|| invalid("HTTP origin must be an exact http(s) origin"))?; + let scheme = scheme.to_ascii_lowercase(); + if scheme != "http" && scheme != "https" { + return Err(invalid("HTTP origin must be an exact http(s) origin")); + } + let host = host.strip_suffix('/').unwrap_or(host); + let authority = authority(host)?; + let port = authority.port_u16(); + let default_port = if scheme == "http" { 80 } else { 443 }; + let suffix = port + .filter(|port| *port != default_port) + .map(|port| format!(":{port}")) + .unwrap_or_default(); + Ok(format!( + "{scheme}://{}{suffix}", + authority.host().to_ascii_lowercase() + )) +} + +pub fn token_matches(supplied: Option<&str>, expected: Option<&str>) -> bool { + let Some(expected) = expected else { + return true; + }; + let Some(supplied) = supplied else { + return false; + }; + let mut difference = supplied.len() ^ expected.len(); + for index in 0..supplied.len().max(expected.len()) { + difference |= usize::from( + supplied.as_bytes().get(index).copied().unwrap_or_default() + ^ expected.as_bytes().get(index).copied().unwrap_or_default(), + ); + } + difference == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn rejects_unsafe_and_ambiguous_listener_settings() { + for value in [ + json!({"host":"0.0.0.0"}), + json!({"host":"[127.0.0.1"}), + json!({"host":"127.0.0.1:80"}), + json!({"port":true}), + json!({"port":1.5}), + json!({"port":65536}), + json!({"token":""}), + json!({"token":"a b"}), + json!({"token":"secret\n"}), + json!({"token":42}), + json!({"origins":["*"]}), + ] { + assert!(HttpSettings::from_value(&value, true).is_err(), "{value}"); + } + assert!(HttpSettings::from_value(&json!({"host":"0.0.0.0","token":"x"}), true).is_ok()); + assert!(HttpSettings::from_value(&json!({"host":"::1","port":0}), true).is_ok()); + } + + #[test] + fn resolves_sparse_settings_in_priority_order() { + let environment = BTreeMap::from([ + ("http_host".to_owned(), json!("0.0.0.0")), + ("http_token".to_owned(), json!("env-token")), + ("http_port".to_owned(), json!("1234")), + ]); + let settings = HttpSettings::resolve( + &BTreeMap::from([("port".to_owned(), json!(0))]), + &json!({"http":{"host":"127.0.0.1"}}), + &environment, + ) + .unwrap(); + assert_eq!(settings.host, "127.0.0.1"); + assert_eq!(settings.port, 0); + assert_eq!(settings.token.as_deref(), Some("env-token")); + assert!( + HttpSettings::resolve(&BTreeMap::new(), &json!({"http":null}), &environment).is_err() + ); + } + + #[test] + fn validates_origins_and_loopback_hosts_without_dns_resolution() { + assert_eq!( + normalize_origin("HTTPS://CLIENT.example:443/").unwrap(), + "https://client.example" + ); + assert_eq!( + normalize_origin("http://[::1]:123/").unwrap(), + "http://[::1]:123" + ); + for origin in [ + "http://a/path", + "http://user@a", + "http://a?", + "http://a#", + "http://a:99999", + "http://a\\b", + "http://a//", + ] { + assert!(normalize_origin(origin).is_err(), "{origin}"); + } + for host in ["localhost:9876", "127.0.0.2", "[::1]:9876"] { + assert!(host_header_is_loopback(host), "{host}"); + } + for host in [ + "127.0.0.1.evil", + "127.0.0.1@evil", + "127.0.0.1/path", + "127.0.0.1:99999", + "127.1", + "2130706433", + "evil", + ] { + assert!(!host_header_is_loopback(host), "{host}"); + } + } + + #[test] + fn checks_full_tokens() { + assert!(token_matches(None, None)); + assert!(!token_matches(None, Some("secret"))); + assert!(!token_matches(Some("secre"), Some("secret"))); + assert!(!token_matches(Some("Secret"), Some("secret"))); + assert!(token_matches(Some("secret"), Some("secret"))); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 631838b..808917a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,11 +3,17 @@ pub mod cache; pub mod cli; pub mod config; +pub mod dashboard; pub mod error; pub mod http; +pub mod http_server; +pub mod http_settings; pub mod ip; pub mod logging; +pub mod mcp; +mod mcp_http; pub mod provider; +mod server_command; pub mod signature; pub mod update; @@ -20,5 +26,9 @@ where I: IntoIterator, S: Into + Clone, { + let arguments: Vec = arguments.into_iter().map(Into::into).collect(); + if let Some(options) = server_command::parse(&arguments)? { + return server_command::run(options); + } update::run(arguments) } diff --git a/rust/src/mcp.rs b/rust/src/mcp.rs new file mode 100644 index 0000000..5662815 --- /dev/null +++ b/rust/src/mcp.rs @@ -0,0 +1,985 @@ +use std::collections::HashMap; +use std::io::{self, BufRead, BufReader, Write}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; + +use serde_json::{Value, json}; + +use crate::dashboard::DashboardService; +use crate::error::Result; + +pub const PROTOCOL_VERSION: &str = "2026-07-28"; +pub const LEGACY_PROTOCOL_VERSION: &str = "2025-11-25"; +pub const PROTOCOL_VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion"; +pub const CLIENT_CAPABILITIES_KEY: &str = "io.modelcontextprotocol/clientCapabilities"; +const CLIENT_INFO_KEY: &str = "io.modelcontextprotocol/clientInfo"; +const SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo"; +const INSTRUCTIONS: &str = "Use get_ddns_status to inspect local cached state. Use update_dns_records only with user approval to update configured DNS records."; +const STATUS_FIELDS: [&str; 6] = [ + "state", + "message", + "last_sync", + "addresses", + "providers", + "records", +]; +const MAX_LINE_BYTES: usize = 1024 * 1024; + +pub fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +fn request_id(message: &Value) -> Option<&Value> { + message + .get("id") + .filter(|id| id.is_string() || id.as_i64().is_some() || id.as_u64().is_some()) +} + +fn server_info() -> Value { + json!({"name": "ddns", "version": env!("CARGO_PKG_VERSION")}) +} + +fn complete(mut result: Value, modern: bool) -> Value { + if modern { + result["resultType"] = json!("complete"); + result["_meta"] = json!({SERVER_INFO_KEY: server_info()}); + } + result +} + +fn valid_client_info(info: &Value) -> bool { + info.is_object() && info["name"].is_string() && info["version"].is_string() +} + +fn validate_meta(params: &Value) -> std::result::Result<&Value, String> { + let meta = ¶ms["_meta"]; + if !meta.is_object() { + return Err("params._meta must be an object.".into()); + } + if !meta[PROTOCOL_VERSION_KEY].is_string() { + return Err(format!("{PROTOCOL_VERSION_KEY} is required.")); + } + if !meta[CLIENT_CAPABILITIES_KEY].is_object() { + return Err(format!("{CLIENT_CAPABILITIES_KEY} is required.")); + } + if !meta[CLIENT_INFO_KEY].is_null() && !valid_client_info(&meta[CLIENT_INFO_KEY]) { + return Err(format!( + "{CLIENT_INFO_KEY} must contain string name and version fields." + )); + } + Ok(meta) +} + +fn tools() -> Value { + json!([ + { + "name": "get_ddns_status", + "title": "Get DDNS status", + "description": "Read configured providers, cached DNS records, addresses, and the latest local sync status.", + "inputSchema": {"type": "object", "additionalProperties": false}, + "annotations": {"title": "Get DDNS status", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true} + }, + { + "name": "update_dns_records", + "title": "Update DNS records", + "description": "Run one complete DDNS synchronization for every record in the configured local file.", + "inputSchema": {"type": "object", "additionalProperties": false}, + "annotations": {"title": "Update DNS records", "readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true} + } + ]) +} + +fn tool_success(status: Value, modern: bool) -> Value { + let status: Value = STATUS_FIELDS + .into_iter() + .map(|field| (field.to_owned(), status[field].clone())) + .collect(); + complete( + json!({"content": [{"type": "text", "text": status.to_string()}], "structuredContent": status, "isError": false}), + modern, + ) +} + +pub struct McpServer { + service: Arc, + modern_only: bool, + legacy_initialized: bool, + legacy_ready: bool, +} + +impl McpServer { + pub fn new(service: Arc, modern_only: bool) -> Self { + Self { + service, + modern_only, + legacy_initialized: false, + legacy_ready: false, + } + } + + fn supported_versions(&self) -> Value { + if self.modern_only { + json!([PROTOCOL_VERSION]) + } else { + json!([PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION]) + } + } + + pub fn handle_message( + &mut self, + message: Value, + cancelled: &(dyn Fn() -> bool + Sync), + ) -> Option { + let id = request_id(&message).cloned().unwrap_or(Value::Null); + if !message.is_object() || message["jsonrpc"] != "2.0" || !message["method"].is_string() { + return Some(error_response(id, -32600, "Invalid Request")); + } + let method = message["method"].as_str().unwrap(); + if message.get("id").is_none() { + if !self.modern_only && method == "notifications/initialized" && self.legacy_initialized + { + self.legacy_ready = true; + } + return None; + } + if id.is_null() { + return Some(error_response(id, -32600, "Invalid Request")); + } + let empty = json!({}); + let params = message.get("params").unwrap_or(&empty); + if !params.is_object() { + return Some(error_response(id, -32602, "Invalid params")); + } + let modern = self.modern_only || (method != "initialize" && !self.legacy_initialized); + if !modern { + if method == "initialize" { + return Some(self.initialize(id, params)); + } + if !self.legacy_ready && method != "ping" { + return Some(error_response(id, -32600, "Server is not initialized")); + } + } else { + if method == "server/discover" && !params["_meta"].is_object() { + return Some(error_response(id, -32601, "Method not found")); + } + let meta = match validate_meta(params) { + Ok(meta) => meta, + Err(message) => return Some(error_response(id, -32602, &message)), + }; + if meta[PROTOCOL_VERSION_KEY] != PROTOCOL_VERSION { + let mut response = error_response(id, -32022, "Unsupported protocol version"); + response["error"]["data"] = json!({"supported": self.supported_versions(), "requested": meta[PROTOCOL_VERSION_KEY]}); + return Some(response); + } + } + Some( + match catch_unwind(AssertUnwindSafe(|| { + self.dispatch(method, params, modern, cancelled) + })) { + Ok(Ok(Some(result))) => json!({"jsonrpc": "2.0", "id": id, "result": result}), + Ok(Ok(None)) => error_response(id, -32601, "Method not found"), + Ok(Err(message)) => error_response(id, -32602, &message), + Err(_) => error_response(id, -32603, "Internal error"), + }, + ) + } + + fn initialize(&mut self, id: Value, params: &Value) -> Value { + let error = if !params["protocolVersion"].is_string() { + Some("protocolVersion is required.") + } else if !params["capabilities"].is_object() { + Some("capabilities must be an object.") + } else if !valid_client_info(¶ms["clientInfo"]) { + Some("clientInfo must contain string name and version fields.") + } else { + None + }; + if let Some(message) = error { + return error_response(id, -32602, message); + } + self.legacy_initialized = true; + self.legacy_ready = false; + json!({"jsonrpc": "2.0", "id": id, "result": { + "protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {"tools": {}}, + "serverInfo": server_info(), "instructions": INSTRUCTIONS + }}) + } + + fn dispatch( + &self, + method: &str, + params: &Value, + modern: bool, + cancelled: &(dyn Fn() -> bool + Sync), + ) -> std::result::Result, String> { + let result = match method { + "server/discover" if modern => complete( + json!({ + "supportedVersions": self.supported_versions(), "capabilities": {"tools": {}}, + "instructions": INSTRUCTIONS, "ttlMs": 3600000, "cacheScope": "public" + }), + true, + ), + "tools/list" => { + if !params["cursor"].is_null() { + return Err("Pagination cursor is not supported.".into()); + } + let mut result = json!({"tools": tools()}); + if modern { + result["ttlMs"] = json!(3600000); + result["cacheScope"] = json!("public"); + } + complete(result, modern) + } + "tools/call" => self.call_tool(params, modern, cancelled)?, + "ping" if !modern => json!({}), + _ => return Ok(None), + }; + Ok(Some(result)) + } + + fn call_tool( + &self, + params: &Value, + modern: bool, + cancelled: &(dyn Fn() -> bool + Sync), + ) -> std::result::Result { + let name = params["name"] + .as_str() + .ok_or("Tool name must be a string.")?; + let empty = json!({}); + let arguments = params + .get("arguments") + .unwrap_or(&empty) + .as_object() + .ok_or("Tool arguments must be an object.")?; + if !arguments.is_empty() { + return Err("This tool does not accept arguments.".into()); + } + let status = match name { + "get_ddns_status" => self.service.dashboard(), + "update_dns_records" => self.service.sync("MCP", cancelled), + _ => return Err(format!("Unknown tool: {name}")), + }; + Ok(match status { + Ok(status) => tool_success(status, modern), + Err(error) => complete( + json!({"content": [{"type": "text", "text": error.to_string()}], "isError": true}), + modern, + ), + }) + } +} + +#[derive(Default)] +struct Pending { + requests: HashMap>>, +} + +impl Pending { + fn register(&mut self, id: &Value) -> Arc { + let flag = Arc::new(AtomicBool::new(false)); + self.requests + .entry(id.to_string()) + .or_default() + .push(flag.clone()); + flag + } + + fn cancel(&self, message: &Value) -> bool { + if message.get("method").and_then(Value::as_str) != Some("notifications/cancelled") { + return false; + } + // Like Python stdio, consume malformed cancellation messages without a response. + if message["jsonrpc"] == "2.0" && message.get("id").is_none() { + let id = &message["params"]["requestId"]; + if (id.is_string() || id.as_i64().is_some() || id.as_u64().is_some()) + && let Some(flags) = self.requests.get(&id.to_string()) + { + for flag in flags { + flag.store(true, Ordering::Release); + } + } + } + true + } + + fn remove(&mut self, id: &Value, flag: &Arc) { + let key = id.to_string(); + if let Some(flags) = self.requests.get_mut(&key) { + flags.retain(|pending| !Arc::ptr_eq(pending, flag)); + if flags.is_empty() { + self.requests.remove(&key); + } + } + } +} + +struct Incoming { + message: Value, + parse_error: bool, + flag: Option>, +} + +// Drain overlong lines without allocating their full length, then resume at the next frame. +fn read_frame(reader: &mut impl BufRead) -> io::Result>> { + let mut line = Vec::new(); + let mut oversized = false; + loop { + let buffer = reader.fill_buf()?; + if buffer.is_empty() { + return Ok(if oversized { + Some(Vec::new()) + } else if line.is_empty() { + None + } else { + Some(line) + }); + } + let end = buffer.iter().position(|byte| *byte == b'\n'); + let count = end.map_or(buffer.len(), |index| index + 1); + if !oversized && line.len() + count <= MAX_LINE_BYTES { + line.extend_from_slice(&buffer[..count]); + } else { + oversized = true; + line.clear(); + } + reader.consume(count); + if end.is_some() { + return Ok(Some(line)); + } + } +} + +pub fn serve_streams( + service: Arc, + mut input: R, + mut output: W, +) -> Result<()> { + let pending = Arc::new(Mutex::new(Pending::default())); + let reader_pending = pending.clone(); + let (sender, receiver) = mpsc::channel(); + // The reader must remain independent of sync so it can observe cancellation in flight. + // It is detached on output failure: stdin may be blocked indefinitely. + thread::Builder::new() + .name("ddns-mcp-reader".into()) + .spawn(move || { + loop { + let line = match read_frame(&mut input) { + Ok(Some(line)) => line, + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error)); + break; + } + }; + let incoming = match serde_json::from_slice::(&line) { + Ok(message) => { + let mut pending = reader_pending + .lock() + .unwrap_or_else(|error| error.into_inner()); + if pending.cancel(&message) { + continue; + } + let flag = request_id(&message).map(|id| pending.register(id)); + Incoming { + message, + parse_error: false, + flag, + } + } + Err(_) => Incoming { + message: error_response(Value::Null, -32700, "Parse error"), + parse_error: true, + flag: None, + }, + }; + if sender.send(Ok(incoming)).is_err() { + break; + } + } + })?; + let mut server = McpServer::new(service, false); + for incoming in receiver { + let incoming = incoming?; + let cancelled = || { + incoming + .flag + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Acquire)) + }; + let id = request_id(&incoming.message).cloned(); + let response = if cancelled() { + None + } else if incoming.parse_error { + Some(incoming.message.clone()) + } else { + server.handle_message(incoming.message.clone(), &cancelled) + }; + // Serialize response publication with cancellation, including the final suppression check. + let mut pending = pending.lock().unwrap_or_else(|error| error.into_inner()); + if let (Some(id), Some(flag)) = (id, &incoming.flag) { + pending.remove(&id, flag); + } + if !cancelled() + && let Some(response) = response + { + serde_json::to_writer(&mut output, &response)?; + output.write_all(b"\n")?; + output.flush()?; + } + } + Ok(()) +} + +pub fn serve_stdio(service: Arc) -> Result<()> { + serve_streams(service, BufReader::new(io::stdin()), io::stdout().lock()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Cursor; + use std::path::PathBuf; + use std::sync::atomic::AtomicUsize; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "ddns-mcp-unit-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + fs::write(path.join("config.json"), json!({"cache": false, "providers": [{"provider": "debug", "ipv4": ["test.example.com"], "index4": "shell:echo 192.0.2.10", "index6": false}]}).to_string()).unwrap(); + Self(path) + } + fn service(&self) -> Arc { + Arc::new(DashboardService::new(Some(self.0.join("config.json")), 5).unwrap()) + } + fn server(&self) -> McpServer { + McpServer::new(self.service(), false) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn request(method: &str, mut params: Value) -> Value { + params["_meta"] = + json!({PROTOCOL_VERSION_KEY: PROTOCOL_VERSION, CLIENT_CAPABILITIES_KEY: {}}); + json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) + } + fn call(server: &mut McpServer, request: Value) -> Value { + server.handle_message(request, &|| false).unwrap() + } + fn initialize() -> Value { + json!({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}}}) + } + + #[test] + fn discovery_tools_and_safety_annotations() { + let fixture = Fixture::new(); + let mut server = fixture.server(); + let result = call(&mut server, request("server/discover", json!({})))["result"].clone(); + assert_eq!( + result["supportedVersions"], + json!([PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION]) + ); + assert_eq!(result["capabilities"], json!({"tools": {}})); + assert_eq!(result["resultType"], "complete"); + assert_eq!(result["_meta"][SERVER_INFO_KEY], server_info()); + assert_eq!(result["instructions"], INSTRUCTIONS); + assert_eq!(result["ttlMs"], 3600000); + assert_eq!(result["cacheScope"], "public"); + let result = call(&mut server, request("tools/list", json!({})))["result"].clone(); + assert_eq!(result["tools"], tools()); + for (index, name, read_only) in [ + (0, "get_ddns_status", true), + (1, "update_dns_records", false), + ] { + let tool = &result["tools"][index]; + assert_eq!(tool["name"], name); + assert_eq!( + tool["inputSchema"], + json!({"type": "object", "additionalProperties": false}) + ); + assert_eq!(tool["annotations"]["readOnlyHint"], read_only); + assert_eq!(tool["annotations"]["destructiveHint"], !read_only); + assert_eq!(tool["annotations"]["idempotentHint"], true); + assert_eq!(tool["annotations"]["openWorldHint"], true); + } + assert_eq!(result["ttlMs"], 3600000); + assert_eq!( + call(&mut server, request("tools/list", json!({"cursor": ""})))["error"]["code"], + -32602 + ); + assert!( + call(&mut server, request("tools/list", json!({"cursor": null})))["result"].is_object() + ); + } + + #[test] + fn validates_envelopes_ids_notifications_and_batches() { + let fixture = Fixture::new(); + let mut server = fixture.server(); + for value in [ + json!([]), + json!([request("tools/list", json!({}))]), + json!(null), + json!(3), + json!({}), + json!({"jsonrpc":"2.0", "method":3}), + ] { + assert_eq!( + call(&mut server, value), + error_response(Value::Null, -32600, "Invalid Request") + ); + } + for id in [ + json!(true), + json!(false), + json!(null), + json!(1.0), + json!([]), + json!({}), + ] { + let mut message = request("tools/list", json!({})); + message["id"] = id; + assert_eq!( + call(&mut server, message), + error_response(Value::Null, -32600, "Invalid Request") + ); + } + for id in [ + json!(0), + json!(-1), + json!(u64::MAX), + json!(""), + json!("测试"), + ] { + let mut message = request("tools/list", json!({})); + message["id"] = id.clone(); + assert_eq!(call(&mut server, message)["id"], id); + } + for method in [ + "notifications/initialized", + "notifications/cancelled", + "tools/call", + "unknown", + ] { + assert!( + server + .handle_message( + json!({"jsonrpc":"2.0", "method":method, "params":true}), + &|| false + ) + .is_none() + ); + } + let mut message = request("tools/list", json!({})); + message["jsonrpc"] = json!("1.0"); + assert_eq!( + call(&mut server, message), + error_response(json!(1), -32600, "Invalid Request") + ); + for params in [json!(null), json!([]), json!(true)] { + assert_eq!( + call( + &mut server, + json!({"jsonrpc":"2.0", "id":1,"method":"tools/list","params":params}) + ), + error_response(json!(1), -32602, "Invalid params") + ); + } + } + + #[test] + fn metadata_validation_and_unknown_methods() { + let fixture = Fixture::new(); + let mut server = fixture.server(); + assert_eq!( + call( + &mut server, + json!({"jsonrpc":"2.0", "id":1,"method":"server/discover"}) + )["error"]["code"], + -32601 + ); + for meta in [ + json!(null), + json!({}), + json!({PROTOCOL_VERSION_KEY: 3}), + json!({PROTOCOL_VERSION_KEY:PROTOCOL_VERSION, CLIENT_CAPABILITIES_KEY:[]}), + json!({PROTOCOL_VERSION_KEY:PROTOCOL_VERSION, CLIENT_CAPABILITIES_KEY:{}, CLIENT_INFO_KEY:{"name":3,"version":"1"}}), + ] { + let mut message = request("tools/list", json!({})); + message["params"]["_meta"] = meta; + assert_eq!(call(&mut server, message)["error"]["code"], -32602); + } + for info in [json!(null), json!({"name":"测试", "version":"1"})] { + let mut message = request("tools/list", json!({})); + message["params"]["_meta"][CLIENT_INFO_KEY] = info; + assert!(call(&mut server, message)["result"].is_object()); + } + for method in ["ping", "resources/list", "tasks/get", "unknown"] { + assert_eq!( + call(&mut server, request(method, json!({})))["error"]["code"], + -32601 + ); + } + let mut message = request("server/discover", json!({})); + message["params"]["_meta"][PROTOCOL_VERSION_KEY] = json!(LEGACY_PROTOCOL_VERSION); + assert_eq!( + call(&mut server, message)["error"], + json!({"code":-32022,"message":"Unsupported protocol version","data":{"supported":[PROTOCOL_VERSION,LEGACY_PROTOCOL_VERSION],"requested":LEGACY_PROTOCOL_VERSION}}) + ); + } + + #[test] + fn legacy_lifecycle_reinitialization_and_modern_only_isolation() { + let fixture = Fixture::new(); + let mut server = fixture.server(); + for (key, invalid) in [ + ("protocolVersion", json!(null)), + ("capabilities", json!([])), + ("clientInfo", json!({"name":"client"})), + ] { + let mut message = initialize(); + message["params"][key] = invalid; + assert_eq!(call(&mut server, message)["error"]["code"], -32602); + } + let result = call(&mut server, initialize())["result"].clone(); + assert_eq!(result["protocolVersion"], LEGACY_PROTOCOL_VERSION); + assert!(result.get("resultType").is_none()); + assert_eq!( + call(&mut server, json!({"jsonrpc":"2.0","id":1,"method":"ping"}))["result"], + json!({}) + ); + assert_eq!( + call(&mut server, request("tools/list", json!({})))["error"]["code"], + -32600 + ); + assert!( + server + .handle_message( + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + &|| false + ) + .is_none() + ); + let result = call( + &mut server, + json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}), + )["result"] + .clone(); + for key in ["resultType", "ttlMs", "cacheScope", "_meta"] { + assert!(result.get(key).is_none()); + } + assert_eq!( + call(&mut server, request("server/discover", json!({})))["error"]["code"], + -32601 + ); + assert_eq!( + call( + &mut server, + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_ddns_status"}}) + )["result"]["isError"], + false + ); + call(&mut server, initialize()); + assert_eq!( + call(&mut server, request("tools/list", json!({})))["error"]["code"], + -32600 + ); + let mut server = McpServer::new(fixture.service(), true); + assert_eq!(call(&mut server, initialize())["error"]["code"], -32602); + assert_eq!( + call(&mut server, request("initialize", json!({})))["error"]["code"], + -32601 + ); + server.handle_message( + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + &|| false, + ); + assert_eq!( + call(&mut server, request("server/discover", json!({})))["result"]["supportedVersions"], + json!([PROTOCOL_VERSION]) + ); + let mut message = request("tools/list", json!({})); + message["params"]["_meta"][PROTOCOL_VERSION_KEY] = json!(LEGACY_PROTOCOL_VERSION); + assert_eq!( + call(&mut server, message)["error"]["data"]["supported"], + json!([PROTOCOL_VERSION]) + ); + } + + #[test] + fn status_whitelist_and_real_debug_sync() { + let result = tool_success( + json!({"state":"synced", "message":"测试", "token":"secret", "config_path":"secret", "scheduler":{"token":"secret"}, "activities":[], "providers":[], "records":[]}), + true, + ); + assert_eq!( + result["structuredContent"].as_object().unwrap().len(), + STATUS_FIELDS.len() + ); + assert_eq!(result["structuredContent"]["last_sync"], Value::Null); + assert!(!result.to_string().contains("secret")); + assert_eq!( + serde_json::from_str::(result["content"][0]["text"].as_str().unwrap()).unwrap(), + result["structuredContent"] + ); + let fixture = Fixture::new(); + let mut server = fixture.server(); + let result = call( + &mut server, + request("tools/call", json!({"name":"update_dns_records"})), + )["result"] + .clone(); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(result["structuredContent"]["state"], "synced"); + assert_eq!( + result["structuredContent"]["records"][0]["value"], + "192.0.2.10" + ); + let result = call( + &mut server, + request("tools/call", json!({"name":"get_ddns_status"})), + )["result"] + .clone(); + assert_eq!(result["isError"], false); + assert_eq!(result["structuredContent"].as_object().unwrap().len(), 6); + } + + #[test] + fn tool_arguments_errors_repair_and_cooperative_cancellation() { + let fixture = Fixture::new(); + let mut server = fixture.server(); + for params in [ + json!({}), + json!({"name":3}), + json!({"name":"missing"}), + json!({"name":"update_dns_records","arguments":null}), + json!({"name":"update_dns_records","arguments":[]}), + json!({"name":"update_dns_records","arguments":{"domain":"injected"}}), + ] { + assert_eq!( + call(&mut server, request("tools/call", params))["error"]["code"], + -32602 + ); + } + let result = server + .handle_message( + request("tools/call", json!({"name":"update_dns_records"})), + &|| true, + ) + .unwrap(); + assert_eq!(result["result"]["isError"], true); + assert!( + result["result"]["content"][0]["text"] + .as_str() + .unwrap() + .to_lowercase() + .contains("cancel") + ); + let original = fs::read(fixture.0.join("config.json")).unwrap(); + fs::write(fixture.0.join("config.json"), "not JSON").unwrap(); + let mut server = fixture.server(); + assert!(call(&mut server, request("server/discover", json!({})))["result"].is_object()); + assert_eq!( + call( + &mut server, + request("tools/call", json!({"name":"update_dns_records"})) + )["result"]["isError"], + true + ); + fs::write(fixture.0.join("config.json"), original).unwrap(); + assert_eq!( + call( + &mut server, + request("tools/call", json!({"name":"update_dns_records"})) + )["result"]["isError"], + false + ); + } + + #[test] + fn cancellation_tracks_only_pending_ids_and_cleans_up_reused_ids() { + let mut pending = Pending::default(); + let cancel = |id| json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":id}}); + for id in 0..10000 { + assert!(pending.cancel(&cancel(json!(id)))); + } + assert!(pending.requests.is_empty()); + let first = pending.register(&json!(1)); + let second = pending.register(&json!(1)); + let string_id = pending.register(&json!("1")); + for message in [ + cancel(json!(true)), + cancel(json!(1.0)), + json!({"jsonrpc":"1.0","method":"notifications/cancelled","params":{"requestId":1}}), + json!({"jsonrpc":"2.0","id":0,"method":"notifications/cancelled","params":{"requestId":1}}), + ] { + assert!(pending.cancel(&message)); + } + assert!(!first.load(Ordering::Acquire)); + pending.cancel(&cancel(json!(1))); + assert!(first.load(Ordering::Acquire)); + assert!(second.load(Ordering::Acquire)); + assert!(!string_id.load(Ordering::Acquire)); + pending.remove(&json!(1), &first); + pending.remove(&json!(1), &second); + pending.remove(&json!("1"), &string_id); + assert!(pending.requests.is_empty()); + pending.cancel(&cancel(json!(1))); + assert!(!pending.register(&json!(1)).load(Ordering::Acquire)); + } + + #[test] + fn stdio_recovers_from_parse_utf8_and_frame_errors() { + let fixture = Fixture::new(); + let mut bytes = b"{\n\xff\n[]\n".to_vec(); + bytes.extend(vec![b'x'; MAX_LINE_BYTES + 20]); + bytes.push(b'\n'); + bytes.extend_from_slice( + b"{\"jsonrpc\":\"1.0\",\"method\":\"notifications/cancelled\",\"id\":9}\n", + ); + bytes.extend_from_slice(request("server/discover", json!({})).to_string().as_bytes()); + let mut output = Vec::new(); + serve_streams(fixture.service(), Cursor::new(bytes), &mut output).unwrap(); + let lines: Vec = String::from_utf8(output) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(lines.len(), 5); + for (line, code) in lines.iter().zip([-32700, -32700, -32600, -32700]) { + assert_eq!(line["error"]["code"], code); + assert!(line["id"].is_null()); + } + assert_eq!(lines[4]["result"]["resultType"], "complete"); + } + + #[test] + fn unexpected_sync_panic_is_a_sanitized_internal_error() { + let fixture = Fixture::new(); + let response = fixture + .server() + .handle_message( + request("tools/call", json!({"name":"update_dns_records"})), + &|| panic!("private-provider-secret"), + ) + .unwrap(); + assert_eq!(response, error_response(json!(1), -32603, "Internal error")); + } + + #[cfg(unix)] + #[test] + fn reader_cancels_running_and_queued_syncs_before_publishing() { + use std::io::Read; + use std::time::{Duration, Instant}; + + struct GatedInput { + current: Cursor>, + phase: u8, + started: PathBuf, + release: PathBuf, + } + impl Read for GatedInput { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let available = self.fill_buf()?; + let count = buffer.len().min(available.len()); + buffer[..count].copy_from_slice(&available[..count]); + self.consume(count); + Ok(count) + } + } + impl BufRead for GatedInput { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + if self.current.position() as usize == self.current.get_ref().len() { + let bytes = match self.phase { + 0 => { + let deadline = Instant::now() + Duration::from_secs(5); + while !self.started.exists() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "sync did not start", + )); + } + thread::sleep(Duration::from_millis(5)); + } + let mut queued = + request("tools/call", json!({"name":"update_dns_records"})); + queued["id"] = json!(2); + format!("{}\n{}\n{}\n", queued, + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}), + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2}})).into_bytes() + } + 1 => { + // Reaching this read proves both cancellation frames were consumed. + fs::write(&self.release, "release")?; + let mut status = + request("tools/call", json!({"name":"get_ddns_status"})); + status["id"] = json!(3); + format!("{}\n{}\n", status, request("server/discover", json!({}))) + .into_bytes() + } + _ => Vec::new(), + }; + self.phase = self.phase.saturating_add(1); + self.current = Cursor::new(bytes); + } + self.current.fill_buf() + } + fn consume(&mut self, amount: usize) { + self.current.consume(amount); + } + } + let fixture = Fixture::new(); + let started = fixture.0.join("started"); + let release = fixture.0.join("release"); + let quote = + |path: &std::path::Path| format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")); + let rule = format!( + "shell:touch {}; i=0; while [ ! -f {} ] && [ $i -lt 500 ]; do sleep 0.01; i=$((i+1)); done; echo 192.0.2.10", + quote(&started), + quote(&release) + ); + fs::write(fixture.0.join("config.json"), json!({"cache":false, "providers":[{"provider":"debug","ipv4":["one.example.com","two.example.com"],"index4":rule,"index6":false}]}).to_string()).unwrap(); + let input = GatedInput { + current: Cursor::new( + format!( + "{}\n", + request("tools/call", json!({"name":"update_dns_records"})) + ) + .into_bytes(), + ), + phase: 0, + started, + release, + }; + let mut output = Vec::new(); + serve_streams(fixture.service(), input, &mut output).unwrap(); + let responses: Vec = String::from_utf8(output) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(responses.len(), 2, "{responses:?}"); + assert_eq!(responses[0]["id"], 3); + assert_eq!( + responses[0]["result"]["structuredContent"]["records"], + json!([]) + ); + assert_eq!(responses[1]["id"], 1); + assert_eq!(responses[1]["result"]["resultType"], "complete"); + } +} diff --git a/rust/src/mcp_http.rs b/rust/src/mcp_http.rs new file mode 100644 index 0000000..eb17ece --- /dev/null +++ b/rust/src/mcp_http.rs @@ -0,0 +1,272 @@ +use std::io::Read; +use std::sync::Arc; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde_json::{Value, json}; + +use crate::dashboard::DashboardService; +use crate::http_server::{Request, Response}; +use crate::http_settings::{HttpSettings, host_header_is_loopback, token_matches}; +use crate::mcp::{ + CLIENT_CAPABILITIES_KEY, McpServer, PROTOCOL_VERSION, PROTOCOL_VERSION_KEY, error_response, +}; + +pub(crate) fn handle( + request: &Request, + reader: &mut impl Read, + settings: &HttpSettings, + service: Arc, +) -> Response { + if request.method == "OPTIONS" { + let Some(origin) = request + .origin() + .filter(|origin| settings.origins.contains(origin)) + else { + return Response::error(403, "invalid_origin", "HTTP request origin is not allowed."); + }; + return Response::empty(204) + .header("Access-Control-Allow-Origin", &origin) + .header("Access-Control-Allow-Methods", "POST, OPTIONS") + .header( + "Access-Control-Allow-Headers", + "Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name", + ) + .header("Access-Control-Max-Age", "600") + .header("Vary", "Origin"); + } + let response = if !request.origin_allowed(settings, true) { + Response::error(403, "invalid_origin", "HTTP request origin is not allowed.") + } else if request.method != "POST" { + Response::error(405, "method_not_allowed", "MCP HTTP only accepts POST.") + .header("Allow", "POST, OPTIONS") + } else if settings.token.is_none() + && !host_header_is_loopback(request.header("host").unwrap_or_default()) + { + Response::error( + 421, + "invalid_host", + "Unauthenticated HTTP only accepts loopback hosts.", + ) + } else if !token_matches(request.token(false), settings.token.as_deref()) { + Response::error(401, "invalid_token", "HTTP bearer token is invalid.") + .header("WWW-Authenticate", "Bearer realm=\"ddns-mcp\"") + } else { + post(request, reader, service) + }; + if let Some(origin) = request + .origin() + .filter(|origin| settings.origins.contains(origin)) + { + response + .header("Access-Control-Allow-Origin", &origin) + .header("Vary", "Origin") + } else { + response + } +} + +fn post(request: &Request, reader: &mut impl Read, service: Arc) -> Response { + if !request + .header("content-type") + .unwrap_or_default() + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + { + return Response::error( + 415, + "unsupported_media_type", + "Content-Type must be application/json.", + ); + } + if !["application/json", "text/event-stream"] + .iter() + .all(|media| accepts(request.header("accept").unwrap_or_default(), media)) + { + return Response::error( + 406, + "not_acceptable", + "Accept must include application/json and text/event-stream.", + ); + } + let message = match request.read_json(reader, true) { + Ok(value) => value, + Err(response) => return response, + }; + if let Err(response) = validate_envelope(request, &message) { + return response; + } + let response = McpServer::new(service, true).handle_message(message, &|| false); + match response { + None => Response::empty(202), + Some(response) => { + let status = match response.pointer("/error/code").and_then(Value::as_i64) { + Some(-32022) => 400, + Some(-32601) => 404, + _ => 200, + }; + Response::json(status, response) + } + } +} + +fn valid_id(value: Option<&Value>) -> Value { + value + .filter(|value| value.is_string() || value.is_i64() || value.is_u64()) + .cloned() + .unwrap_or(Value::Null) +} + +fn validate_envelope(request: &Request, message: &Value) -> std::result::Result<(), Response> { + let id = valid_id(message.get("id")); + if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") + || !message.get("method").is_some_and(Value::is_string) + || (message.get("id").is_some() && id.is_null()) + { + return Err(Response::json( + 400, + error_response(id, -32600, "Invalid Request"), + )); + } + if message + .get("params") + .is_some_and(|params| !params.is_object()) + { + return Err(Response::json( + 400, + error_response(id, -32602, "Invalid params"), + )); + } + let mismatch = |detail: &str| Response::json(400, error_response(id.clone(), -32020, detail)); + for name in ["MCP-Protocol-Version", "Mcp-Method", "Mcp-Name"] { + if request.header_count(name) > 1 { + return Err(mismatch("MCP routing headers must not be repeated.")); + } + } + let meta = message.get("params").and_then(|params| params.get("_meta")); + let body_version = meta + .and_then(|meta| meta.get(PROTOCOL_VERSION_KEY)) + .and_then(Value::as_str); + if request.header("MCP-Protocol-Version").is_none() + || request.header("MCP-Protocol-Version") != body_version + { + return Err(mismatch( + "MCP-Protocol-Version does not match request metadata.", + )); + } + if body_version != Some(PROTOCOL_VERSION) { + let mut response = error_response(id, -32022, "Unsupported protocol version"); + response["error"]["data"] = + json!({"supported":[PROTOCOL_VERSION],"requested":body_version}); + return Err(Response::json(400, response)); + } + let method = message.get("method").and_then(Value::as_str); + if request.header("Mcp-Method").is_none() || request.header("Mcp-Method") != method { + return Err(mismatch("Mcp-Method does not match the JSON-RPC method.")); + } + if !meta + .and_then(|meta| meta.get(CLIENT_CAPABILITIES_KEY)) + .is_some_and(Value::is_object) + { + return Err(Response::json( + 400, + error_response(id, -32602, "Client capabilities metadata is required."), + )); + } + if let Some(field) = match method { + Some("tools/call" | "prompts/get") => Some("name"), + Some("resources/read") => Some("uri"), + _ => None, + } { + let header = request.header("Mcp-Name").and_then(decode_name); + let body = message + .get("params") + .and_then(|params| params.get(field)) + .and_then(Value::as_str); + if header.as_deref().is_none_or(str::is_empty) || header.as_deref() != body { + return Err(mismatch("Mcp-Name does not match the named request.")); + } + } + Ok(()) +} + +fn accepts(header: &str, media: &str) -> bool { + header.split(',').any(|entry| { + let mut parts = entry.split(';').map(str::trim); + if !parts + .next() + .is_some_and(|value| value.eq_ignore_ascii_case(media)) + { + return false; + } + let mut quality = 1.0_f64; + for part in parts { + if let Some((name, value)) = part.split_once('=') + && name.trim().eq_ignore_ascii_case("q") + { + let Ok(value) = value.trim().parse::() else { + return false; + }; + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return false; + } + quality = value; + } + } + quality > 0.0 + }) +} + +fn decode_name(value: &str) -> Option { + let value = value.trim_matches([' ', '\t']); + if let Some(encoded) = value + .strip_prefix("=?base64?") + .and_then(|value| value.strip_suffix("?=")) + { + return String::from_utf8(BASE64.decode(encoded).ok()?).ok(); + } + value + .bytes() + .all(|byte| (0x20..=0x7e).contains(&byte)) + .then(|| value.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn negotiates_only_positive_finite_quality_values() { + assert!(accepts( + "application/json, text/event-stream", + "application/json" + )); + for header in [ + "application/json;q=0", + "application/json;q=NaN", + "application/json;q=inf", + "application/json;q=1.1", + "application/json;q=invalid", + "*/*", + ] { + assert!(!accepts(header, "application/json"), "{header}"); + } + } + + #[test] + fn decodes_named_request_header() { + assert_eq!( + decode_name(" get_ddns_status\t").as_deref(), + Some("get_ddns_status") + ); + assert_eq!( + decode_name("=?base64?Z2V0X2RkbnNfc3RhdHVz?=").as_deref(), + Some("get_ddns_status") + ); + assert!(decode_name("=?base64?bad?=").is_none()); + assert!(decode_name("bad\nname").is_none()); + } +} diff --git a/rust/src/provider/debug.rs b/rust/src/provider/debug.rs index 4166b16..dc6cf8e 100644 --- a/rust/src/provider/debug.rs +++ b/rust/src/provider/debug.rs @@ -2,7 +2,9 @@ use crate::error::Result; use super::base::{Provider, RecordRequest}; -pub struct DebugProvider; +pub struct DebugProvider { + pub emit: bool, +} impl Provider for DebugProvider { fn set_record(&mut self, request: &RecordRequest<'_>) -> Result<()> { @@ -11,7 +13,9 @@ impl Provider for DebugProvider { "AAAA" => "IPv6", value => value, }; - println!("[{label}] {}", request.address); + if self.emit { + println!("[{label}] {}", request.address); + } Ok(()) } } diff --git a/rust/src/provider/mod.rs b/rust/src/provider/mod.rs index cb215db..e6e9040 100644 --- a/rust/src/provider/mod.rs +++ b/rust/src/provider/mod.rs @@ -125,6 +125,15 @@ pub fn build<'a>( config: &Config, client: &'a dyn HttpClient, logger: Logger, +) -> Result> { + build_with_output(config, client, logger, true) +} + +pub fn build_with_output<'a>( + config: &Config, + client: &'a dyn HttpClient, + logger: Logger, + emit_debug: bool, ) -> Result> { let provider = config.provider; let context = base::ProviderContext { @@ -139,7 +148,7 @@ pub fn build<'a>( logger, }; match provider { - ProviderId::Debug => Ok(Box::new(debug::DebugProvider)), + ProviderId::Debug => Ok(Box::new(debug::DebugProvider { emit: emit_debug })), ProviderId::Cloudflare => Ok(Box::new(cloudflare::CloudflareProvider::new(context)?)), ProviderId::Alidns => Ok(Box::new(alidns::AlidnsProvider::new(context)?)), ProviderId::Dnspod => Ok(Box::new(dnspod::DnspodProvider::new(context)?)), diff --git a/rust/src/server_command.rs b/rust/src/server_command.rs new file mode 100644 index 0000000..35b0044 --- /dev/null +++ b/rust/src/server_command.rs @@ -0,0 +1,333 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use serde_json::{Value, json}; + +use crate::config; +use crate::dashboard::DashboardService; +use crate::error::{Error, Result}; +use crate::http_server::HttpServer; +use crate::http_settings::HttpSettings; + +const WEB_HELP: &str = "Usage: ddns-rs web [-c FILE] [OPTIONS]\n\nServe the embedded dashboard and /mcp endpoint.\n -c, --config FILE One local configuration file\n --host HOST Bind address (default: 127.0.0.1)\n --port PORT Listener port (default: 9876; 0 selects an unused port)\n --http-token TOKEN Shared HTTP bearer token; required outside loopback\n --http-origin URL Allowed MCP browser origin (repeatable)\n --interval MINUTES Web sync interval, 1..1440 (default: 5)\n --open Open the dashboard in a browser\n -h, --help Show this help\n"; +const MCP_HELP: &str = "Usage: ddns-rs mcp [-c FILE] [OPTIONS]\n\nServe DDNS status and synchronization tools.\n -c, --config FILE One local configuration file\n --transport MODE stdio (default) or http\n --host HOST HTTP bind address (default: 127.0.0.1)\n --port PORT HTTP port (default: 9876; 0 selects an unused port)\n --http-token TOKEN Shared HTTP bearer token; required outside loopback\n --http-origin URL Allowed MCP browser origin (repeatable)\n -h, --help Show this help\n"; + +#[derive(Debug)] +pub struct ServerOptions { + web: bool, + http: bool, + config_path: Option, + settings: BTreeMap, + interval: Option, + open: bool, + help: bool, +} + +pub fn parse(arguments: &[OsString]) -> Result> { + let args: Vec<&str> = match arguments + .iter() + .map(|arg| arg.to_str()) + .collect::>>() + { + Some(args) => args, + None => return Ok(None), + }; + let mode = args.get(1).copied(); + let explicit = matches!(mode, Some("web" | "mcp")); + if !explicit + && args.iter().any(|arg| { + matches!(*arg, "--help" | "-h" | "--version" | "-v" | "--new-config") + || arg.starts_with("--new-config=") + }) + { + return Ok(None); + } + let interval_flag = args + .iter() + .any(|arg| *arg == "--interval" || arg.starts_with("--interval=")); + let environment = config::env::load(); + if !explicit && !interval_flag && !has_config_interval(arguments, &environment)? { + return Ok(None); + } + let web = mode != Some("mcp"); + let mut options = ServerOptions { + web, + http: web, + config_path: None, + settings: BTreeMap::new(), + interval: None, + open: false, + help: false, + }; + let mut index = if explicit { 2 } else { 1 }; + let mut paths = Vec::new(); + while index < args.len() { + let (name, inline) = args[index] + .split_once('=') + .map_or((args[index], None), |(name, value)| (name, Some(value))); + index += 1; + match name { + "-h" | "--help" if inline.is_none() => { + options.help = true; + return Ok(Some(options)); + } + "--open" if web && inline.is_none() => options.open = true, + "-c" | "--config" => paths.push(value(&args, &mut index, inline, name)?.to_owned()), + "--host" | "--port" | "--http-token" | "--http_token" => { + let key = match name { + "--host" => "host", + "--port" => "port", + _ => "token", + }; + options.settings.insert( + key.to_owned(), + json!(value(&args, &mut index, inline, name)?), + ); + } + "--http-origin" | "--http_origins" => { + let origin = value(&args, &mut index, inline, name)?; + options + .settings + .entry("origins".to_owned()) + .or_insert_with(|| json!([])) + .as_array_mut() + .expect("origin array") + .push(json!(origin)); + } + "--interval" if web => { + options.interval = Some(parse_interval(value(&args, &mut index, inline, name)?)?) + } + "--transport" if !web => { + options.http = match value(&args, &mut index, inline, name)? { + "stdio" => false, + "http" => true, + _ => { + return Err(Error::Usage( + "MCP transport must be stdio or http".to_owned(), + )); + } + } + } + _ => { + return Err(Error::Usage(format!( + "unsupported {} option: {name}", + if web { "web" } else { "mcp" } + ))); + } + } + } + if !options.http && !options.settings.is_empty() { + return Err(Error::Usage( + "MCP HTTP options require --transport http".to_owned(), + )); + } + if paths.is_empty() + && let Some(value) = environment.get("config") + { + paths = config::value_list(Some(value), false)?; + } + if paths.len() > 1 { + return Err(Error::Usage( + "Web and MCP require exactly one local configuration file".to_owned(), + )); + } + if let Some(path) = paths.first() { + if path.is_empty() || path.contains("://") { + return Err(Error::Usage( + "Web and MCP only support a local configuration file".to_owned(), + )); + } + options.config_path = Some(config::file::expand_home(path)); + } else { + options.config_path = config::file::existing_default().map(PathBuf::from); + } + Ok(Some(options)) +} + +fn value<'a>( + args: &[&'a str], + index: &mut usize, + inline: Option<&'a str>, + name: &str, +) -> Result<&'a str> { + if let Some(value) = inline { + return Ok(value); + } + let value = args + .get(*index) + .copied() + .filter(|value| !value.starts_with('-')) + .ok_or_else(|| Error::Usage(format!("{name} requires a value")))?; + *index += 1; + Ok(value) +} + +fn parse_interval(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| (1..=1440).contains(value)) + .ok_or_else(|| { + Error::Usage("interval must be an integer between 1 and 1440 minutes".to_owned()) + }) +} + +fn has_config_interval( + arguments: &[OsString], + environment: &BTreeMap, +) -> Result { + let mut selected = None; + let mut arguments = arguments.iter().skip(1).peekable(); + while let Some(argument) = arguments.next() { + let Some(argument) = argument.to_str() else { + continue; + }; + if matches!(argument, "-c" | "--config") { + let paths = selected.get_or_insert_with(Vec::new); + while arguments + .peek() + .is_some_and(|value| !value.to_string_lossy().starts_with('-')) + { + paths.push( + arguments + .next() + .expect("peeked argument") + .to_string_lossy() + .into_owned(), + ); + } + } else if let Some(path) = argument.strip_prefix("--config=") { + selected.get_or_insert_with(Vec::new).push(path.to_owned()); + } + } + let paths = if let Some(paths) = selected { + paths + } else if let Some(value) = environment.get("config") { + config::value_list(Some(value), false)? + } else { + config::file::existing_default().into_iter().collect() + }; + Ok(paths + .iter() + .filter(|path| !path.contains("://")) + .any(|path| { + std::fs::read_to_string(config::file::expand_home(path)) + .ok() + .and_then(|content| config::file::parse(&content).ok()) + .is_some_and(|document| { + document + .get("interval") + .is_some_and(|value| !value.is_null()) + }) + })) +} + +pub fn run(options: ServerOptions) -> Result<()> { + if options.help { + print!("{}", if options.web { WEB_HELP } else { MCP_HELP }); + return Ok(()); + } + let document = options + .config_path + .as_ref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|content| config::file::parse(&content).ok()) + .unwrap_or_else(|| json!({})); + let interval = if let Some(interval) = options.interval { + interval + } else if options.web + && let Some(value) = document.get("interval") + { + value + .as_u64() + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| (1..=1440).contains(value)) + .ok_or_else(|| { + Error::Config("interval must be an integer between 1 and 1440 minutes".to_owned()) + })? + } else { + 5 + }; + let service = Arc::new( + DashboardService::new(options.config_path, interval) + .map_err(|error| Error::Config(error.to_string()))?, + ); + if !options.http { + return crate::mcp::serve_stdio(service); + } + let settings = HttpSettings::resolve(&options.settings, &document, &config::env::load())?; + let server = HttpServer::bind(Arc::clone(&service), settings, options.web)?; + let url = server.launch_url()?; + let _scheduler = options.web.then(|| service.start_scheduler()); + println!( + "DDNS {}: {url}", + if options.web { "dashboard" } else { "MCP HTTP" } + ); + std::io::stdout().flush()?; + if options.open && open_browser(&url).is_err() { + eprintln!("Unable to open a browser automatically; use the printed dashboard URL."); + } + server.serve(&AtomicBool::new(false)) +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("rundll32.exe"); + command.arg("url.dll,FileProtocolHandler"); + command + }; + #[cfg(target_os = "macos")] + let mut command = Command::new("open"); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let mut command = Command::new("xdg-open"); + let mut child = command.arg(url).spawn()?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(OsString::from).collect() + } + + #[test] + fn parses_server_modes_and_rejects_ignored_options() { + let web = parse(&args(&["ddns-rs", "web", "--port", "0", "--interval=7"])) + .unwrap() + .unwrap(); + assert!(web.web && web.http); + assert_eq!(web.interval, Some(7)); + let mcp = parse(&args(&[ + "ddns-rs", + "mcp", + "--transport=http", + "--http-origin", + "https://example.com", + ])) + .unwrap() + .unwrap(); + assert!(!mcp.web && mcp.http); + for arguments in [ + vec!["ddns-rs", "mcp", "--host", "127.0.0.1"], + vec!["ddns-rs", "web", "-c", "a", "-c", "b"], + vec!["ddns-rs", "web", "-c", "https://example.com/config"], + vec!["ddns-rs", "web", "--token", "secret"], + vec!["ddns-rs", "web", "--interval", "0"], + vec!["ddns-rs", "web", "--interval", "1.5"], + vec!["ddns-rs", "mcp", "--transport", "other"], + ] { + assert!(parse(&args(&arguments)).is_err(), "{arguments:?}"); + } + } +} diff --git a/rust/src/update.rs b/rust/src/update.rs index c3c0dcd..124774b 100644 --- a/rust/src/update.rs +++ b/rust/src/update.rs @@ -82,128 +82,17 @@ fn run_options(options: cli::CliOptions) -> Result<()> { Ok(response.body) }; let configs = config::load(&options, &environment, &fetch)?; - let mut failures = Vec::new(); - - for (index, config) in configs.iter().enumerate() { - let secrets = provider_secrets(config.provider, &config.id, &config.token); - let logger = match Logger::new( - config.log.level, - config.log.file.as_deref(), - secrets.clone(), - ) { - Ok(logger) => logger, - Err(error) => { - let logger = Logger::stderr(config.log.level, secrets); - let message = masked_error(&logger, &error); - logger.error("config", format!("failed to configure log file: {message}")); - failures.push(format!("configuration {} log setup: {message}", index + 1)); - logger - } - }; - if config.log.format.is_some() || config.log.date_format.is_some() { - logger.warning( - "config", - "custom Python log format strings are accepted but not rendered by the Rust MVP", - ); - } - if [&config.index4, &config.index6].iter().any(|rules| { - matches!( - rules, - AddressRules::Rules(rules) - if rules - .iter() - .any(|rule| rule.starts_with("cmd:") || rule.starts_with("shell:")) - ) - }) { - logger.warning( - "config", - "cmd: and shell: address rules execute local commands; use only trusted configuration sources", - ); - } - logger.info( - "ddns", - format!( - "running configuration {}/{} with provider {}", - index + 1, - configs.len(), - config.provider - ), - ); - let client = UreqClient::new(logger.clone(), config.tls.clone()); - let mut provider = match provider::build(config, &client, logger.clone()) { - Ok(provider) => provider, - Err(error) => { - let message = masked_error(&logger, &error); - logger.error("ddns", &message); - failures.push(format!("configuration {}: {message}", index + 1)); - continue; - } - }; - let mut cache = match Cache::open( - &config.cache, - &config.cache_identity(), - config.cache_max_age, - logger.clone(), - ) { - Ok(cache) => cache, - Err(error) => { - let message = masked_error(&logger, &error); - logger.error("cache", &message); - failures.push(format!("configuration {} cache: {message}", index + 1)); - None - } - }; - - for (family, rules, domains) in [ - (AddressFamily::V4, &config.index4, &config.ipv4), - (AddressFamily::V6, &config.index6, &config.ipv6), - ] { - let AddressRules::Rules(rules) = rules else { - continue; - }; - if domains.is_empty() { - continue; - } - let address = match ip::resolve(family, rules, &client, &logger) { - Ok(address) => address, - Err(error) => { - let message = masked_error(&logger, &error); - logger.error("ip", &message); - failures.push(format!( - "configuration {} {} discovery: {message}", - index + 1, - family.record_type() - )); - continue; - } - }; - failures.extend( - update_domains( - provider.as_mut(), - config.provider, - cache.as_mut(), - family, - address, - domains, - config.ttl, - config.line.as_deref(), - &config.extra, - &logger, - ) - .into_iter() - .map(|failure| format!("configuration {} {failure}", index + 1)), - ); - } - - if let Some(cache) = &mut cache - && let Err(error) = cache.sync() - { - let message = masked_error(&logger, &error); - logger.error("cache", &message); - failures.push(format!("configuration {} cache sync: {message}", index + 1)); - } - } - + let outcomes = update_configs_with_output(&configs, &|| false, true); + let failures: Vec<_> = outcomes + .iter() + .enumerate() + .flat_map(|(index, outcome)| { + outcome + .failures + .iter() + .map(move |failure| format!("configuration {} {failure}", index + 1)) + }) + .collect(); if failures.is_empty() { Ok(()) } else { @@ -215,6 +104,180 @@ fn run_options(options: cli::CliOptions) -> Result<()> { } } +#[derive(Clone, Debug)] +pub struct UpdatedRecord { + pub domain: String, + pub record_type: &'static str, + pub address: String, + pub changed: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct UpdateOutcome { + pub failures: Vec, + pub records: Vec, + pub cancelled: bool, +} + +pub fn update_configs( + configs: &[config::Config], + cancelled: &(dyn Fn() -> bool + Sync), +) -> Vec { + update_configs_with_output(configs, cancelled, false) +} + +fn update_configs_with_output( + configs: &[config::Config], + cancelled: &(dyn Fn() -> bool + Sync), + emit_debug: bool, +) -> Vec { + let mut outcomes = Vec::new(); + for config in configs { + let outcome = update_config_with_output(config, cancelled, emit_debug); + let stopped = outcome.cancelled; + outcomes.push(outcome); + if stopped { + break; + } + } + outcomes +} + +pub fn update_config( + config: &config::Config, + cancelled: &(dyn Fn() -> bool + Sync), +) -> UpdateOutcome { + update_config_with_output(config, cancelled, false) +} + +fn update_config_with_output( + config: &config::Config, + cancelled: &(dyn Fn() -> bool + Sync), + emit_debug: bool, +) -> UpdateOutcome { + let mut result = UpdateOutcome::default(); + if cancelled() { + result.cancelled = true; + return result; + } + let secrets = provider_secrets(config.provider, &config.id, &config.token); + let logger = match Logger::new( + config.log.level, + config.log.file.as_deref(), + secrets.clone(), + ) { + Ok(logger) => logger, + Err(error) => { + let logger = Logger::stderr(config.log.level, secrets); + let message = masked_error(&logger, &error); + logger.error("config", format!("failed to configure log file: {message}")); + result.failures.push(format!("log setup: {message}")); + logger + } + }; + if config.log.format.is_some() || config.log.date_format.is_some() { + logger.warning( + "config", + "custom Python log format strings are accepted but not rendered by the Rust MVP", + ); + } + if [&config.index4, &config.index6].iter().any(|rules| { + matches!( + rules, + AddressRules::Rules(rules) + if rules + .iter() + .any(|rule| rule.starts_with("cmd:") || rule.starts_with("shell:")) + ) + }) { + logger.warning( + "config", + "cmd: and shell: address rules execute local commands; use only trusted configuration sources", + ); + } + logger.info("ddns", format!("running provider {}", config.provider)); + let client = UreqClient::new(logger.clone(), config.tls.clone()); + let mut provider = + match provider::build_with_output(config, &client, logger.clone(), emit_debug) { + Ok(provider) => provider, + Err(error) => { + let message = masked_error(&logger, &error); + logger.error("ddns", &message); + result.failures.push(message); + return result; + } + }; + let mut cache = match Cache::open( + &config.cache, + &config.cache_identity(), + config.cache_max_age, + logger.clone(), + ) { + Ok(cache) => cache, + Err(error) => { + let message = masked_error(&logger, &error); + logger.error("cache", &message); + result.failures.push(format!("cache: {message}")); + None + } + }; + + for (family, rules, domains) in [ + (AddressFamily::V4, &config.index4, &config.ipv4), + (AddressFamily::V6, &config.index6, &config.ipv6), + ] { + if cancelled() { + result.cancelled = true; + break; + } + let AddressRules::Rules(rules) = rules else { + continue; + }; + if domains.is_empty() { + continue; + } + let address = match ip::resolve(family, rules, &client, &logger) { + Ok(address) => address, + Err(error) => { + let message = masked_error(&logger, &error); + logger.error("ip", &message); + result + .failures + .push(format!("{} discovery: {message}", family.record_type())); + continue; + } + }; + let domain_result = update_domains( + provider.as_mut(), + config.provider, + cache.as_mut(), + family, + address, + domains, + config.ttl, + config.line.as_deref(), + &config.extra, + &logger, + cancelled, + ); + result.failures.extend(domain_result.failures); + result.records.extend(domain_result.records); + if domain_result.cancelled { + result.cancelled = true; + break; + } + } + + if let Some(cache) = &mut cache + && let Err(error) = cache.sync() + { + let message = masked_error(&logger, &error); + logger.error("cache", &message); + result.failures.push(format!("cache sync: {message}")); + } + result +} + #[allow(clippy::too_many_arguments)] fn update_domains( provider: &mut dyn provider::Provider, @@ -227,10 +290,15 @@ fn update_domains( line: Option<&str>, extra: &std::collections::BTreeMap, logger: &Logger, -) -> Vec { - let mut failures = Vec::new(); + cancelled: &(dyn Fn() -> bool + Sync), +) -> UpdateOutcome { + let mut result = UpdateOutcome::default(); let address = address.to_string(); for domain in domains { + if cancelled() { + result.cancelled = true; + break; + } let domain = domain.to_ascii_lowercase(); if cache .as_deref() @@ -246,6 +314,12 @@ fn update_domains( address ), ); + result.records.push(UpdatedRecord { + domain, + record_type: family.record_type(), + address: address.clone(), + changed: false, + }); continue; } let request = RecordRequest { @@ -267,6 +341,12 @@ fn update_domains( address ), ); + result.records.push(UpdatedRecord { + domain: domain.clone(), + record_type: family.record_type(), + address: address.clone(), + changed: true, + }); if let Some(cache) = cache.as_deref_mut() { cache.set(provider_id, &domain, family.record_type(), &address); } @@ -281,11 +361,13 @@ fn update_domains( family.record_type() ), ); - failures.push(format!("{}[{}]: {message}", domain, family.record_type())); + result + .failures + .push(format!("{}[{}]: {message}", domain, family.record_type())); } } } - failures + result } fn masked_error(logger: &Logger, error: &impl std::fmt::Display) -> String { @@ -393,7 +475,9 @@ mod tests { None, &BTreeMap::new(), &logger, - ); + &|| false, + ) + .failures; assert_eq!(provider.calls, vec!["fail.example.com", "ok.example.com"]); assert_eq!(failures.len(), 1); assert!(failures[0].contains("fail.example.com")); diff --git a/rust/tests/cli_e2e.rs b/rust/tests/cli_e2e.rs index 3332e35..f0f9fdd 100644 --- a/rust/tests/cli_e2e.rs +++ b/rust/tests/cli_e2e.rs @@ -234,7 +234,7 @@ fn falls_back_from_invalid_literals_to_labeled_addresses() { #[test] fn reports_unsupported_modes_with_usage_exit_code() { - let output = command().arg("web").output().unwrap(); + let output = command().arg("task").output().unwrap(); assert_eq!(output.status.code(), Some(2)); assert!(String::from_utf8_lossy(&output.stderr).contains("not supported")); } diff --git a/rust/tests/dashboard_service.rs b/rust/tests/dashboard_service.rs new file mode 100644 index 0000000..62d6da7 --- /dev/null +++ b/rust/tests/dashboard_service.rs @@ -0,0 +1,377 @@ +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use ddns_rs::dashboard::DashboardService; +use serde_json::{Value, json}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "ddns-dashboard-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn service(&self) -> DashboardService { + DashboardService::new(Some(self.0.join("config.json")), 5).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn document() -> Value { + json!({"cache": false, "log": {"level": "CRITICAL"}, "providers": [{"provider": "debug", "ipv4": ["test.example.com"], "index4": ["shell:echo 192.0.2.10"], "index6": false}]}) +} + +#[test] +fn repairs_missing_and_invalid_config_and_swaps_backup() { + let fixture = Fixture::new(); + let service = fixture.service(); + assert_eq!(service.config_state().unwrap()["exists"], false); + fs::write(service.config_path(), "not json secret-token").unwrap(); + let state = service.config_state().unwrap(); + assert!(state.get("validation_error").is_some()); + assert_eq!(state["raw"], "not json secret-token"); + assert!( + !service + .dashboard() + .unwrap() + .to_string() + .contains("secret-token") + ); + service.save(document()).unwrap(); + assert!(service.restore_backup().is_err()); + let mut second = document(); + second["interval"] = json!(12); + service.save(second).unwrap(); + assert_eq!( + service.restore_backup().unwrap()["config"].get("interval"), + None + ); + assert_eq!(service.restore_backup().unwrap()["config"]["interval"], 12); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [ + service.config_path().to_path_buf(), + fixture.0.join("config.json.bak"), + ] { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} + +#[test] +fn normalizes_legacy_preserves_sparse_fields_and_scopes_metadata() { + let fixture = Fixture::new(); + let service = fixture.service(); + let value = service.validate(json!({"dns":"DEBUG", "id":"account", "token":"secret", "http":{"token":"http-secret"}, "interval":10, "log_level":"INFO", "extra_region":"west", "custom":42})).unwrap(); + assert_eq!(value["providers"][0]["provider"], "debug"); + assert_eq!( + value["providers"][0]["extra"], + json!({"region":"west", "custom":42}) + ); + assert!(value["providers"][0].get("http").is_none()); + assert!(value["providers"][0].get("interval").is_none()); + assert!(value["providers"][0].get("index4").is_none()); + assert_eq!(value["log"], json!({"level":"INFO"})); + let sparse = json!({"providers":[{"provider":"debug"}]}); + let validated = service.validate(sparse).unwrap(); + let configs = ddns_rs::config::from_document( + validated, + &std::collections::BTreeMap::from([ + ("token".into(), json!("environment-secret")), + ("ipv4".into(), json!(["env.example.com"])), + ]), + ) + .unwrap(); + assert_eq!(configs[0].token, "environment-secret"); + assert_eq!(configs[0].ipv4, ["env.example.com"]); + for invalid in [ + json!({"providers":[{"provider":"debug","http":{}}]}), + json!({"providers":[], "interval":0}), + json!({"providers":[{"provider":"debug","extra":{"domain":"override"}}]}), + json!({"providers":[{"provider":"debug","ipv4":["not a domain"]}]}), + ] { + assert!(service.validate(invalid).is_err()); + } +} + +#[test] +fn debug_sync_retains_cache_disabled_records_and_redacts_status() { + fn send_sync() {} + send_sync::(); + let fixture = Fixture::new(); + let service = fixture.service(); + let mut value = document(); + value["providers"][0]["token"] = json!("private-token"); + value["http"] = json!({"token":"private-http-token"}); + service.save(value).unwrap(); + let state = service.sync("private-token", &|| false).unwrap(); + assert_eq!(state["state"], "synced"); + assert_eq!(state["providers"][0]["status"], "synced"); + assert_eq!(state["records"][0]["value"], "192.0.2.10"); + assert!(!state.to_string().contains("private-token")); + assert!(!state.to_string().contains("private-http-token")); +} + +#[test] +fn cancellation_stops_before_and_between_domains() { + let fixture = Fixture::new(); + let service = fixture.service(); + let mut value = document(); + value["providers"][0]["ipv4"] = json!(["one.example.com", "two.example.com"]); + service.save(value).unwrap(); + assert_eq!( + service.sync("test", &|| true).unwrap_err().code, + "cancelled" + ); + let count = AtomicUsize::new(0); + let result = service.sync("test", &|| count.fetch_add(1, Ordering::SeqCst) >= 3); + assert!(result.is_err()); + assert!( + service.dashboard().unwrap()["records"] + .as_array() + .unwrap() + .len() + <= 1 + ); +} + +#[test] +fn duplicate_provider_failure_is_indexed_and_loopback_ip_is_real() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut buf = [0; 4096]; + let _ = stream.read(&mut buf).unwrap(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\n192.0.2.20", + ) + .unwrap(); + }); + let fixture = Fixture::new(); + let service = fixture.service(); + let mut value = document(); + value["providers"][0]["index4"] = json!([format!("url:http://{address}/ip")]); + let mut second = value["providers"][0].clone(); + second["index4"] = json!(["shell:echo no-address"]); + value["providers"].as_array_mut().unwrap().push(second); + service.save(value).unwrap(); + assert!(service.sync("test", &|| false).is_err()); + server.join().unwrap(); + let state = service.dashboard().unwrap(); + assert_eq!(state["providers"][0]["status"], "synced"); + assert_eq!(state["providers"][1]["status"], "error"); + assert_eq!(state["records"][0]["value"], "192.0.2.20"); +} + +#[test] +fn reads_actual_cache_scoped_to_configured_domains() { + let fixture = Fixture::new(); + let service = fixture.service(); + let mut value = document(); + value["cache"] = json!(fixture.0.join("cache")); + service.save(value).unwrap(); + service.sync("test", &|| false).unwrap(); + let reopened = fixture.service(); + let dashboard = reopened.dashboard().unwrap(); + assert_eq!(dashboard["records"][0]["value"], "192.0.2.10"); + assert!(dashboard["records"][0]["updated"].as_f64().is_some()); + assert_eq!(dashboard["last_sync"], dashboard["records"][0]["updated"]); +} + +#[test] +fn scheduler_is_opt_in_configurable_and_stops_on_drop() { + let fixture = Fixture::new(); + let service = Arc::new(fixture.service()); + assert_eq!(service.dashboard().unwrap()["scheduler"]["active"], false); + assert!(service.configure_scheduler("takeover", "web", 5).is_err()); + assert!(service.configure_scheduler("enable", "cron", 5).is_err()); + assert!(service.configure_scheduler("configure", "web", 0).is_err()); + let handle = service.start_scheduler(); + assert_eq!( + service.configure_scheduler("disable", "web", 1).unwrap()["enabled"], + false + ); + assert_eq!( + service.configure_scheduler("enable", "web", 2).unwrap()["interval"], + 2 + ); + assert_eq!(service.dashboard().unwrap()["last_sync"], Value::Null); + drop(handle); + assert_eq!(service.dashboard().unwrap()["scheduler"]["active"], false); +} + +#[cfg(unix)] +#[test] +fn save_and_restore_preserve_symlink_target() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + let target = fixture.0.join("real.json"); + fs::write(&target, "{'dns':'debug'}").unwrap(); + symlink("real.json", fixture.0.join("config.json")).unwrap(); + let service = fixture.service(); + service.save(document()).unwrap(); + assert!( + fs::symlink_metadata(service.config_path()) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(fixture.0.join("real.json.bak").exists()); + service.restore_backup().unwrap(); + assert_eq!(fs::read_to_string(target).unwrap(), "{'dns':'debug'}"); +} + +#[test] +fn mutation_waits_for_real_callback_sync_without_deadlocking() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (started, received) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "callback request ended before its headers"); + request.extend_from_slice(&buffer[..count]); + assert!( + request.len() <= 64 * 1024, + "callback request headers are too large" + ); + } + started.send(()).unwrap(); + released.recv_timeout(Duration::from_secs(5)).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK") + .unwrap(); + }); + let fixture = Fixture::new(); + let service = Arc::new(fixture.service()); + let mut value = document(); + value["providers"][0]["provider"] = json!("callback"); + value["providers"][0]["id"] = json!(format!("http://{address}/update?secret=hidden")); + service.save(value).unwrap(); + let updater = service.clone(); + let sync = thread::spawn(move || updater.sync("mcp", &|| false)); + received.recv_timeout(Duration::from_secs(5)).unwrap(); + let writer = service.clone(); + let (saved, done) = mpsc::channel(); + let write = thread::spawn(move || { + let result = writer.save(document()); + saved.send(()).unwrap(); + result + }); + assert!(done.recv_timeout(Duration::from_millis(30)).is_err()); + release.send(()).unwrap(); + let status = sync.join().unwrap().unwrap(); + assert_eq!(status["records"].as_array().unwrap().len(), 1); + assert!(!status.to_string().contains("hidden")); + write.join().unwrap().unwrap(); + server.join().unwrap(); + assert_eq!(service.dashboard().unwrap()["providers"][0]["id"], "debug"); +} + +#[test] +fn canonical_validation_rejects_duplicates_and_invalid_metadata() { + let fixture = Fixture::new(); + let service = fixture.service(); + for invalid in [ + json!({"providers":[{"provider":"debug","ipv4":["UPPER.example.com","upper.example.com"]}]}), + json!({"providers":[{"provider":"debug","extra_domain":"override"}]}), + json!({"providers":[{"provider":"debug","index4":["url:http://"]}]}), + json!({"providers":[],"http":{"host":"127.0.0.1@evil"}}), + json!({"providers":[],"http":{"origins":["http://example.com/path"]}}), + ] { + assert!(service.validate(invalid).is_err()); + } + let value = service.validate(json!({"providers":[{"provider":"debug","ipv4":["UPPER.example.com"],"index6":[],"ttl":"60"}]})).unwrap(); + assert_eq!(value["providers"][0]["ipv4"], json!(["upper.example.com"])); + assert_eq!(value["providers"][0]["index6"], false); + assert_eq!(value["providers"][0]["ttl"], 60); +} + +#[test] +fn constructor_interval_wins_and_sparse_http_is_normalized() { + let fixture = Fixture::new(); + fs::write( + fixture.0.join("config.json"), + r#"{"interval":12,"providers":[]}"#, + ) + .unwrap(); + let service = DashboardService::new(Some(fixture.0.join("config.json")), 3).unwrap(); + assert_eq!(service.dashboard().unwrap()["scheduler"]["interval"], 3); + let value = service + .validate(json!({"providers":[],"http":{"origins":["HTTPS://CLIENT.example:443/"]}})) + .unwrap(); + assert_eq!(value["http"], json!({"origins":["https://client.example"]})); + assert_eq!( + service + .validate(json!({"providers":{},"token":"hidden"})) + .unwrap_err() + .code, + "invalid_config" + ); + service.save(json!({"interval":8,"providers":[]})).unwrap(); + assert_eq!(service.dashboard().unwrap()["scheduler"]["interval"], 8); + service.restore_backup().unwrap(); + assert_eq!(service.dashboard().unwrap()["scheduler"]["interval"], 12); +} + +#[test] +fn restores_backup_when_current_file_is_missing() { + let fixture = Fixture::new(); + let service = fixture.service(); + fs::write(fixture.0.join("config.json.bak"), r#"{"dns":"debug"}"#).unwrap(); + assert!( + service.restore_backup().unwrap()["exists"] + .as_bool() + .unwrap() + ); + assert!(fixture.0.join("config.json.bak").is_file()); +} + +#[test] +fn rejects_direct_reserved_record_overrides() { + let fixture = Fixture::new(); + let service = fixture.service(); + for key in ["domain", "value", "record_type"] { + let mut global = json!({"providers": [{"provider": "debug"}]}); + global[key] = json!("override"); + assert!(service.validate(global).is_err()); + let mut provider = json!({"providers": [{"provider": "debug"}]}); + provider["providers"][0][key] = json!("override"); + assert!(service.validate(provider).is_err()); + } +} diff --git a/rust/tests/http_e2e.rs b/rust/tests/http_e2e.rs new file mode 100644 index 0000000..110752e --- /dev/null +++ b/rust/tests/http_e2e.rs @@ -0,0 +1,609 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use ddns_rs::dashboard::DashboardService; +use ddns_rs::http_server::HttpServer; +use ddns_rs::http_settings::HttpSettings; +use ddns_rs::mcp::{CLIENT_CAPABILITIES_KEY, PROTOCOL_VERSION, PROTOCOL_VERSION_KEY}; +use serde_json::{Value, json}; + +struct TestServer { + address: SocketAddr, + stop: Arc, + thread: Option>, + directory: PathBuf, + launch: String, +} + +impl TestServer { + fn new(web: bool, token: Option<&str>) -> Self { + let mut random = [0; 8]; + getrandom::fill(&mut random).unwrap(); + let directory = std::env::temp_dir().join(format!( + "ddns-http-test-{}-{}", + std::process::id(), + u64::from_ne_bytes(random) + )); + fs::create_dir(&directory).unwrap(); + let path = directory.join("config.json"); + fs::write( + &path, + serde_json::to_vec(&config("home.example.com")).unwrap(), + ) + .unwrap(); + let service = Arc::new(DashboardService::new(Some(path), 5).unwrap()); + let settings = HttpSettings::from_value(&json!({"host":"127.0.0.1","port":0,"token":token,"origins":["https://client.example"]}), true).unwrap(); + let server = HttpServer::bind(service, settings, web).unwrap(); + let address = server.local_addr().unwrap(); + let launch = server.launch_url().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let stopping = Arc::clone(&stop); + let thread = thread::spawn(move || server.serve(&stopping).unwrap()); + Self { + address, + stop, + thread: Some(thread), + directory, + launch, + } + } + + fn request(&self, method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Response { + let host = if headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("host")) + { + String::new() + } else { + format!("Host: {}\r\n", self.address) + }; + let mut request = format!( + "{method} {path} HTTP/1.1\r\n{host}Content-Length: {}\r\n", + body.len() + ); + for (name, value) in headers { + request.push_str(&format!("{name}: {value}\r\n")); + } + request.push_str("\r\n"); + request.push_str(body); + self.raw(&request) + } + + fn raw(&self, request: &str) -> Response { + let mut stream = TcpStream::connect(self.address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream.write_all(request.as_bytes()).unwrap(); + let mut output = Vec::new(); + if let Err(error) = stream.read_to_end(&mut output) { + assert!(!output.is_empty(), "read response: {error}"); + } + Response::parse(output) + } + + fn rpc(&self, message: &Value, overrides: &[(&str, &str)]) -> Response { + let mut headers = BTreeMap::from([ + ("Content-Type", "application/json"), + ("Accept", "application/json, text/event-stream"), + ( + "MCP-Protocol-Version", + message["params"]["_meta"][PROTOCOL_VERSION_KEY] + .as_str() + .unwrap_or(PROTOCOL_VERSION), + ), + ( + "Mcp-Method", + message["method"].as_str().unwrap_or("tools/list"), + ), + ]); + if message["method"] == "tools/call" { + headers.insert("Mcp-Name", message["params"]["name"].as_str().unwrap()); + } + headers.extend(overrides.iter().copied()); + self.request( + "POST", + "/mcp", + &headers.into_iter().collect::>(), + &message.to_string(), + ) + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + fs::remove_dir_all(&self.directory).unwrap(); + } +} + +struct Response { + status: u16, + headers: BTreeMap, + body: Vec, +} +impl Response { + fn parse(output: Vec) -> Self { + let end = output + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("response framing"); + let headers = String::from_utf8(output[..end].to_vec()).unwrap(); + let mut lines = headers.split("\r\n"); + let status = lines + .next() + .unwrap() + .split(' ') + .nth(1) + .unwrap() + .parse() + .unwrap(); + let headers = lines + .map(|line| { + let (name, value) = line.split_once(':').unwrap(); + (name.to_ascii_lowercase(), value.trim().to_owned()) + }) + .collect(); + Self { + status, + headers, + body: output[end + 4..].to_vec(), + } + } + fn json(&self) -> Value { + serde_json::from_slice(&self.body).unwrap() + } +} + +fn config(domain: &str) -> Value { + json!({"cache":false,"providers":[{"provider":"debug","id":"provider-id-secret","token":"provider-token-secret","ipv4":[domain],"index4":"shell:echo 192.0.2.44","index6":false}]}) +} + +fn message(method: &str, mut params: Value) -> Value { + params["_meta"] = json!({PROTOCOL_VERSION_KEY:PROTOCOL_VERSION,CLIENT_CAPABILITIES_KEY:{}}); + json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}) +} + +#[test] +fn serves_only_embedded_assets_with_security_headers_and_head() { + let server = TestServer::new(true, None); + for path in [ + "/", + "/index.html", + "/dashboard", + "/dashboard/", + "/assets/dashboard.js", + "/assets/dashboard.css", + "/assets/ddns.svg", + ] { + let response = server.request("GET", path, &[], ""); + assert_eq!(response.status, 200, "{path}"); + assert!(!response.body.is_empty()); + assert_eq!(response.headers["x-content-type-options"], "nosniff"); + assert!(response.headers["content-security-policy"].contains("frame-ancestors 'none'")); + let head = server.request("HEAD", path, &[], ""); + assert_eq!(head.status, 200); + assert!(head.body.is_empty()); + assert_eq!( + head.headers["content-length"], + response.body.len().to_string() + ); + } + for path in [ + "/config.json", + "/../config.json", + "/assets/../../config.json", + "/assets/%2e%2e/config.json", + ] { + assert_eq!(server.request("GET", path, &[], "").status, 404); + } +} + +#[test] +fn protects_api_and_bootstraps_browser_exactly_once() { + let server = TestServer::new(true, Some("http-test-token")); + assert!(!server.launch.contains("http-test-token")); + assert_eq!(server.request("GET", "/api/config", &[], "").status, 403); + assert_eq!( + server + .request("GET", "/api/config?token=http-test-token", &[], "") + .status, + 403 + ); + let path = server.launch.split_once("/launch/").unwrap().1; + let path = format!("/launch/{path}"); + assert_eq!(server.request("HEAD", &path, &[], "").status, 403); + let launch = server.request("GET", &path, &[], ""); + assert_eq!(launch.status, 302); + assert_eq!( + launch.headers["location"], + "/#token=http-test-token&view=overview" + ); + assert_eq!(server.request("GET", &path, &[], "").status, 403); + for headers in [ + vec![("X-DDNS-Token", "http-test-token")], + vec![ + ("Authorization", "Bearer http-test-token"), + ("Host", "reverse-proxy.example"), + ], + ] { + assert_eq!( + server.request("GET", "/api/config", &headers, "").status, + 200 + ); + } + let status = server.request( + "GET", + "/api/dashboard", + &[("Authorization", "Bearer http-test-token")], + "", + ); + assert_eq!(status.status, 200); + assert!(!String::from_utf8(status.body).unwrap().contains("secret")); +} + +#[test] +fn rejects_dns_rebinding_and_cross_origin_web_writes() { + let server = TestServer::new(true, None); + for host in [ + "attacker.example", + "127.0.0.1.attacker.example", + "127.0.0.1:bad", + "127.0.0.1/path", + "user@127.0.0.1", + ] { + assert_eq!( + server + .request("GET", "/api/config", &[("Host", host)], "") + .status, + 421, + "{host}" + ); + } + for origin in ["https://evil.example", "null", "https://client.example"] { + assert_eq!( + server + .request( + "POST", + "/api/sync", + &[("Content-Type", "application/json"), ("Origin", origin)], + "{}" + ) + .status, + 403 + ); + } + assert_eq!(server.request("GET", "/api/config", &[], "").status, 200); +} + +#[test] +fn configuration_validation_persistence_restore_and_sync_use_real_service() { + let server = TestServer::new(true, None); + let headers = [("Content-Type", "application/json")]; + let original = server.request("GET", "/api/config", &[], "").json(); + assert!(original["model"]["providers"].is_array()); + let replacement = config("new.example.com"); + let body = json!({"config":replacement}).to_string(); + let validation = server.request("POST", "/api/config/validate", &headers, &body); + assert_eq!(validation.status, 200); + assert_eq!( + server.request("GET", "/api/config", &[], "").json()["config"], + original["config"] + ); + let saved = server.request("PUT", "/api/config", &headers, &body); + assert_eq!(saved.status, 200); + assert_eq!(saved.json()["backup_available"], true); + assert_eq!( + saved.json()["config"]["providers"][0]["ipv4"][0], + "new.example.com" + ); + let sync = server.request("POST", "/api/sync", &headers, "{}"); + assert_eq!(sync.status, 200, "{}", String::from_utf8_lossy(&sync.body)); + assert_eq!(sync.json()["state"], "synced"); + assert_eq!(sync.json()["records"][0]["value"], "192.0.2.44"); + let restored = server.request("POST", "/api/config/restore", &headers, "{}"); + assert_eq!(restored.status, 200); + assert_eq!( + restored.json()["config"]["providers"][0]["ipv4"][0], + "home.example.com" + ); + let invalid = server.request( + "PUT", + "/api/config", + &headers, + r#"{"config":{"providers":{}}}"#, + ); + assert_eq!(invalid.status, 400); + assert_eq!( + server.request("GET", "/api/config", &[], "").json()["config"], + restored.json()["config"] + ); +} + +#[test] +fn scheduler_controls_validate_interval_without_installing_system_tasks() { + let server = TestServer::new(true, None); + let headers = [("Content-Type", "application/json")]; + let disabled = server.request( + "POST", + "/api/scheduler", + &headers, + r#"{"action":"disable","scheduler":"web","interval":7}"#, + ); + assert_eq!(disabled.status, 200); + assert_eq!(disabled.json()["scheduler"]["enabled"], false); + for interval in [json!(true), json!(1.5), json!(0), json!(1441), json!("5")] { + let response = server.request( + "POST", + "/api/scheduler", + &headers, + &json!({"action":"enable","interval":interval}).to_string(), + ); + assert_eq!(response.status, 400); + } + assert_eq!( + server + .request( + "POST", + "/api/scheduler", + &headers, + r#"{"action":"takeover"}"# + ) + .status, + 501 + ); +} + +#[test] +fn modern_mcp_discovery_tools_notifications_and_shared_state() { + let server = TestServer::new(true, None); + let discover = server.rpc(&message("server/discover", json!({})), &[]); + assert_eq!(discover.status, 200); + assert_eq!( + discover.json()["result"]["supportedVersions"], + json!([PROTOCOL_VERSION]) + ); + let tools = server.rpc(&message("tools/list", json!({})), &[]); + assert_eq!(tools.json()["result"]["tools"].as_array().unwrap().len(), 2); + let updated = server.rpc( + &message( + "tools/call", + json!({"name":"update_dns_records","arguments":{}}), + ), + &[], + ); + assert_eq!(updated.status, 200); + assert_eq!(updated.json()["result"]["isError"], false); + let status = server.rpc( + &message("tools/call", json!({"name":"get_ddns_status"})), + &[], + ); + assert_eq!( + status.json()["result"]["structuredContent"]["state"], + "synced" + ); + assert!( + status.json()["result"]["structuredContent"] + .get("config_path") + .is_none() + ); + assert!(!String::from_utf8(status.body).unwrap().contains("secret")); + assert_eq!( + server.request("GET", "/api/dashboard", &[], "").json()["state"], + "synced" + ); + let mut notification = message("notifications/cancelled", json!({"requestId":1})); + notification.as_object_mut().unwrap().remove("id"); + let response = server.rpc(¬ification, &[]); + assert_eq!(response.status, 202); + assert!(response.body.is_empty()); +} + +#[test] +fn standalone_mcp_has_no_dashboard_and_requires_bearer_auth() { + let server = TestServer::new(false, Some("http-test-token")); + assert_eq!(server.request("GET", "/", &[], "").status, 404); + assert_eq!(server.request("GET", "/api/config", &[], "").status, 404); + let request = message("tools/list", json!({})); + let rejected = server.rpc(&request, &[]); + assert_eq!(rejected.status, 401); + assert!(rejected.headers.contains_key("www-authenticate")); + assert_eq!( + server + .rpc(&request, &[("X-DDNS-Token", "http-test-token")]) + .status, + 401 + ); + assert_eq!( + server + .rpc(&request, &[("Authorization", "Bearer http-test-token")]) + .status, + 200 + ); +} + +#[test] +fn mcp_validates_media_routing_headers_and_jsonrpc_before_dispatch() { + let server = TestServer::new(false, None); + let request = message("tools/call", json!({"name":"get_ddns_status"})); + for (headers, status, code) in [ + ( + vec![("MCP-Protocol-Version", "2025-11-25")], + 400, + Some(-32020), + ), + (vec![("Mcp-Method", "tools/list")], 400, Some(-32020)), + (vec![("Mcp-Name", "update_dns_records")], 400, Some(-32020)), + (vec![("Accept", "application/json")], 406, None), + ( + vec![("Accept", "application/json;q=0, text/event-stream")], + 406, + None, + ), + (vec![("Content-Type", "text/plain")], 415, None), + ] { + let response = server.rpc(&request, &headers); + assert_eq!(response.status, status); + if let Some(code) = code { + assert_eq!(response.json()["error"]["code"], code); + } + } + let mut legacy = request.clone(); + legacy["params"]["_meta"][PROTOCOL_VERSION_KEY] = json!("2025-11-25"); + assert_eq!(server.rpc(&legacy, &[]).json()["error"]["code"], -32022); + let mut invalid = request.clone(); + invalid["id"] = json!([]); + let response = server.rpc(&invalid, &[("Mcp-Method", "mismatch")]); + assert_eq!(response.status, 400); + assert_eq!(response.json()["error"]["code"], -32600); + assert!(response.json()["id"].is_null()); + assert_eq!( + server + .rpc(&message("resources/list", json!({})), &[]) + .status, + 404 + ); + assert_eq!( + server + .rpc( + &message("resources/read", json!({"uri":"file:///status"})), + &[] + ) + .json()["error"]["code"], + -32020 + ); + assert_eq!( + server + .rpc( + &message("resources/read", json!({"uri":"file:///status"})), + &[("Mcp-Name", "file:///status")] + ) + .status, + 404 + ); + assert_eq!( + server + .rpc(&request, &[("Mcp-Name", "=?base64?Z2V0X2RkbnNfc3RhdHVz?=")]) + .status, + 200 + ); +} + +#[test] +fn rejects_duplicate_mcp_routing_headers_and_batch_requests() { + let server = TestServer::new(false, None); + let body = message("tools/list", json!({})).to_string(); + let response = server.raw(&format!("POST /mcp HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\nMCP-Protocol-Version: {PROTOCOL_VERSION}\r\nMCP-Protocol-Version: {PROTOCOL_VERSION}\r\nMcp-Method: tools/list\r\n\r\n{body}",server.address,body.len())); + assert_eq!(response.status, 400); + assert_eq!(response.json()["error"]["code"], -32020); + let response = server.request( + "POST", + "/mcp", + &[ + ("Content-Type", "application/json"), + ("Accept", "application/json, text/event-stream"), + ], + "[]", + ); + assert_eq!(response.json()["error"]["code"], -32600); +} + +#[test] +fn cors_is_exact_and_applies_to_transport_errors_only_on_mcp() { + let server = TestServer::new(false, None); + let request = message("tools/list", json!({})); + for origin in ["https://evil.example", "null"] { + assert_eq!(server.rpc(&request, &[("Origin", origin)]).status, 403); + assert_eq!( + server + .request("GET", "/mcp", &[("Origin", origin)], "") + .status, + 403 + ); + } + let preflight = server.request( + "OPTIONS", + "/mcp", + &[("Origin", "https://client.example")], + "", + ); + assert_eq!(preflight.status, 204); + assert_eq!( + preflight.headers["access-control-allow-origin"], + "https://client.example" + ); + assert_eq!( + server + .request("OPTIONS", "/mcp", &[("Origin", "https://evil.example")], "") + .status, + 403 + ); + let response = server.rpc( + &request, + &[ + ("Origin", "https://client.example"), + ("Mcp-Method", "wrong"), + ], + ); + assert_eq!(response.status, 400); + assert_eq!( + response.headers["access-control-allow-origin"], + "https://client.example" + ); + let direct = format!("http://{}", server.address); + assert_eq!(server.rpc(&request, &[("Origin", &direct)]).status, 200); + let method = server.request("GET", "/mcp", &[], ""); + assert_eq!(method.status, 405); + assert_eq!(method.headers["allow"], "POST, OPTIONS"); +} + +#[test] +fn bounds_request_framing_and_rejects_auth_before_reading_body() { + let server = TestServer::new(true, Some("http-test-token")); + let started = Instant::now(); + let response = server.raw(&format!("POST /mcp HTTP/1.1\r\nHost: {}\r\nContent-Length: 100\r\nContent-Type: application/json\r\n\r\n",server.address)); + assert_eq!(response.status, 401); + assert!(started.elapsed() < Duration::from_secs(2)); + for extra in [ + "Content-Length: 0\r\nContent-Length: 0\r\n", + "Transfer-Encoding: chunked\r\n", + "Host: duplicate\r\n", + ] { + let response = server.raw(&format!( + "POST /api/sync HTTP/1.1\r\nHost: {}\r\n{extra}\r\n", + server.address + )); + assert_eq!(response.status, 400); + } + let too_large = server.raw(&format!("POST /api/sync HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer http-test-token\r\nContent-Type: application/json\r\nContent-Length: 2097153\r\n\r\n",server.address)); + assert_eq!(too_large.status, 413); + assert_eq!(server.raw("bad request\r\n\r\n").status, 400); +} + +#[test] +fn authentication_rejection_delivers_complete_response_for_buffered_body() { + let server = TestServer::new(false, Some("http-test-token")); + let body = "x".repeat(128 * 1024); + let response = server.request( + "POST", + "/mcp", + &[("Content-Type", "application/json")], + &body, + ); + assert_eq!(response.status, 401); + assert_eq!( + response.headers["content-length"], + response.body.len().to_string() + ); + assert_eq!(response.json()["error"]["code"], "invalid_token"); +} diff --git a/rust/tests/mcp_stdio.rs b/rust/tests/mcp_stdio.rs new file mode 100644 index 0000000..ade4397 --- /dev/null +++ b/rust/tests/mcp_stdio.rs @@ -0,0 +1,283 @@ +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use ddns_rs::mcp::{ + CLIENT_CAPABILITIES_KEY, LEGACY_PROTOCOL_VERSION, PROTOCOL_VERSION, PROTOCOL_VERSION_KEY, +}; +use serde_json::{Value, json}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "ddns-mcp-stdio-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + let fixture = Self(path); + fixture.write_config(); + fixture + } + fn write_config(&self) { + fs::write(self.0.join("config.json"), json!({ + "cache":false, "providers":[{"provider":"debug", "token":"test-private-provider-token", "ipv4":["one.example.com", "two.example.com"], "index4":"shell:echo 192.0.2.44", "index6":false}] + }).to_string()).unwrap(); + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct Process { + child: Child, + lines: Receiver, + stderr: Option>, +} +impl Process { + fn start(fixture: &Fixture) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_ddns-rs")); + for (key, _) in std::env::vars_os() { + if key + .to_string_lossy() + .to_ascii_uppercase() + .starts_with("DDNS_") + { + command.env_remove(key); + } + } + let mut child = command + .current_dir(&fixture.0) + .args(["mcp", "--config", "config.json"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, lines) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if sender.send(line.unwrap()).is_err() { + break; + } + } + }); + let mut stderr = child.stderr.take().unwrap(); + let stderr = thread::spawn(move || { + let mut text = String::new(); + stderr.read_to_string(&mut text).unwrap(); + text + }); + Self { + child, + lines, + stderr: Some(stderr), + } + } + fn send(&mut self, message: Value) { + self.raw(format!("{message}\n").as_bytes()); + } + fn raw(&mut self, bytes: &[u8]) { + let input = self.child.stdin.as_mut().unwrap(); + input.write_all(bytes).unwrap(); + input.flush().unwrap(); + } + fn receive(&self) -> Value { + let line = self + .lines + .recv_timeout(Duration::from_secs(10)) + .expect("MCP response timeout or unexpected stdout EOF"); + serde_json::from_str(&line) + .unwrap_or_else(|error| panic!("non-JSON stdout: {line:?}: {error}")) + } + fn finish(mut self) -> String { + self.child.stdin.take(); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = self.child.try_wait().unwrap() { + break status; + } + assert!(Instant::now() < deadline, "MCP failed to exit on EOF"); + thread::sleep(Duration::from_millis(10)); + }; + let stderr = self.stderr.take().unwrap().join().unwrap(); + assert!(status.success(), "MCP exit {status}: {stderr}"); + assert!( + self.lines.recv_timeout(Duration::from_secs(1)).is_err(), + "unexpected extra stdout response" + ); + stderr + } +} +impl Drop for Process { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn request(id: Value, method: &str, mut params: Value) -> Value { + params["_meta"] = json!({PROTOCOL_VERSION_KEY:PROTOCOL_VERSION, CLIENT_CAPABILITIES_KEY:{}, "io.modelcontextprotocol/clientInfo":{"name":"测试客户端", "version":"1"}}); + json!({"jsonrpc":"2.0", "id":id, "method":method, "params":params}) +} +fn tool(id: i64, name: &str) -> Value { + request( + json!(id), + "tools/call", + json!({"name":name, "arguments":{}}), + ) +} +fn assert_status(response: &Value) { + assert_eq!(response["result"]["isError"], false, "{response}"); + let status = &response["result"]["structuredContent"]; + let mut keys = status + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(); + keys.sort(); + assert_eq!( + keys, + [ + "addresses", + "last_sync", + "message", + "providers", + "records", + "state" + ] + ); + assert_eq!( + serde_json::from_str::(response["result"]["content"][0]["text"].as_str().unwrap()) + .unwrap(), + *status + ); + assert!(!response.to_string().contains("test-private-provider-token")); +} + +#[test] +fn modern_stdio_discovery_full_sync_status_and_stdout_purity() { + let fixture = Fixture::new(); + let mut process = Process::start(&fixture); + process.send(request(json!("discover"), "server/discover", json!({}))); + let response = process.receive(); + assert_eq!(response["id"], "discover"); + assert_eq!( + response["result"]["supportedVersions"], + json!([PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION]) + ); + process.send(request(json!(2), "tools/list", json!({}))); + assert_eq!( + process.receive()["result"]["tools"] + .as_array() + .unwrap() + .len(), + 2 + ); + process.send(tool(3, "get_ddns_status")); + assert_status(&process.receive()); + process.send(tool(4, "update_dns_records")); + let response = process.receive(); + assert_status(&response); + assert_eq!(response["result"]["structuredContent"]["state"], "synced"); + let records = response["result"]["structuredContent"]["records"] + .as_array() + .unwrap(); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|record| record["value"] == "192.0.2.44")); + process.send(tool(5, "get_ddns_status")); + assert_status(&process.receive()); + process.finish(); +} + +#[test] +fn legacy_stdio_initialization_gate_ping_and_sync() { + let fixture = Fixture::new(); + let mut process = Process::start(&fixture); + process.send(json!({"jsonrpc":"2.0","id":0,"method":"server/discover"})); + assert_eq!(process.receive()["error"]["code"], -32601); + process.send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":LEGACY_PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"copilot","version":"1"}}})); + let response = process.receive(); + assert_eq!( + response["result"]["protocolVersion"], + LEGACY_PROTOCOL_VERSION + ); + assert!(response["result"].get("resultType").is_none()); + process.send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})); + assert_eq!(process.receive()["error"]["code"], -32600); + process.send(json!({"jsonrpc":"2.0","id":3,"method":"ping"})); + assert_eq!(process.receive()["result"], json!({})); + process.send(json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + process.send(json!({"jsonrpc":"2.0","id":4,"method":"tools/list"})); + let response = process.receive(); + assert_eq!(response["id"], 4); + assert!(response["result"].get("ttlMs").is_none()); + process.send(json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"update_dns_records"}})); + let response = process.receive(); + assert_status(&response); + assert!(response["result"].get("resultType").is_none()); + process.finish(); +} + +#[test] +fn stdio_parse_errors_batches_invalid_ids_and_unknown_cancellation() { + let fixture = Fixture::new(); + let mut process = Process::start(&fixture); + process.raw(b"{\n\xff\n[]\n"); + for code in [-32700, -32700, -32600] { + let response = process.receive(); + assert_eq!(response["error"]["code"], code); + assert!(response["id"].is_null()); + } + for id in [json!(null), json!(true), json!(1.5), json!({})] { + process.send(request(id, "tools/list", json!({}))); + assert_eq!(process.receive()["error"]["code"], -32600); + } + process.send( + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":42}}), + ); + process.send(json!({"jsonrpc":"1.0","id":7,"method":"notifications/cancelled"})); + process.send( + json!({"jsonrpc":"2.0","method":"tools/call","params":{"name":"update_dns_records"}}), + ); + process.send(request(json!(42), "server/discover", json!({}))); + assert_eq!(process.receive()["id"], 42); + process.send(tool(43, "get_ddns_status")); + let response = process.receive(); + assert_status(&response); + assert!( + response["result"]["structuredContent"]["records"] + .as_array() + .unwrap() + .is_empty() + ); + process.finish(); +} + +#[test] +fn invalid_configuration_keeps_stdio_alive_and_is_repairable() { + let fixture = Fixture::new(); + fs::write(fixture.0.join("config.json"), "not json").unwrap(); + let mut process = Process::start(&fixture); + process.send(request(json!(1), "server/discover", json!({}))); + assert_eq!(process.receive()["result"]["resultType"], "complete"); + process.send(tool(2, "update_dns_records")); + let response = process.receive(); + assert_eq!(response["result"]["isError"], true); + assert!(response.get("error").is_none()); + fixture.write_config(); + process.send(tool(3, "update_dns_records")); + assert_status(&process.receive()); + process.finish(); +} diff --git a/rust/tests/server_cli.rs b/rust/tests/server_cli.rs new file mode 100644 index 0000000..ecc73ff --- /dev/null +++ b/rust/tests/server_cli.rs @@ -0,0 +1,240 @@ +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let mut random = [0; 8]; + getrandom::fill(&mut random).unwrap(); + let directory = std::env::temp_dir().join(format!( + "ddns-server-cli-{}-{}", + std::process::id(), + u64::from_ne_bytes(random) + )); + fs::create_dir(&directory).unwrap(); + Self(directory) + } + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_ddns-rs")); + command.current_dir(&self.0); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy().to_ascii_uppercase(); + if name.starts_with("DDNS_") || name == "PYTHONHTTPSVERIFY" { + command.env_remove(key); + } + } + command + } + fn write(&self, document: Value) { + fs::write(self.0.join("config.json"), document.to_string()).unwrap(); + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +struct Running(Child); +impl Running { + fn start(mut command: Command) -> (Self, String) { + let mut process = Self( + command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ); + let stdout = process.0.stdout.take().unwrap(); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let mut line = String::new(); + let result = BufReader::new(stdout).read_line(&mut line); + sender.send((result, line)).unwrap(); + }); + let (result, line) = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("server startup timeout"); + result.unwrap(); + assert!(!line.is_empty(), "server exited before announcing listener"); + (process, line) + } +} +impl Drop for Running { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn get(address: &str, path: &str) -> (u16, Value) { + let mut stream = TcpStream::connect(address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + stream + .write_all(format!("GET {path} HTTP/1.1\r\nHost: {address}\r\n\r\n").as_bytes()) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let status = response.split(' ').nth(1).unwrap().parse().unwrap(); + let body = response.split_once("\r\n\r\n").unwrap().1; + (status, serde_json::from_str(body).unwrap()) +} + +#[test] +fn web_help_and_mcp_help_do_not_start_services() { + let fixture = Fixture::new(); + for mode in ["web", "mcp"] { + let output = fixture.command().args([mode, "--help"]).output().unwrap(); + assert!(output.status.success()); + assert!( + String::from_utf8(output.stdout) + .unwrap() + .contains(&format!("Usage: ddns-rs {mode}")) + ); + } +} + +#[test] +fn starts_explicit_and_inferred_web_with_cli_interval_priority() { + let fixture = Fixture::new(); + fixture.write(json!({"interval":7,"http":{"port":0},"providers":[]})); + for args in [ + vec!["web", "-c", "config.json", "--interval", "9"], + vec!["-c", "config.json", "--interval", "9"], + vec!["-c", "config.json", "--port", "0"], + ] { + let mut command = fixture.command(); + command.args(&args); + let (_process, line) = Running::start(command); + assert!(line.starts_with("DDNS dashboard: http://"), "{line}"); + let address = line + .split("http://") + .nth(1) + .unwrap() + .split('/') + .next() + .unwrap(); + let (status, dashboard) = get(address, "/api/dashboard"); + assert_eq!(status, 200); + assert_eq!(dashboard["scheduler"]["active"], true); + assert_eq!( + dashboard["scheduler"]["interval"], + if args.contains(&"9") { 9 } else { 7 } + ); + } +} + +#[test] +fn starts_standalone_http_mcp_without_dashboard() { + let fixture = Fixture::new(); + fixture.write(json!({"providers":[]})); + let mut command = fixture.command(); + command.args([ + "mcp", + "--transport", + "http", + "--port", + "0", + "-c", + "config.json", + ]); + let (_process, line) = Running::start(command); + assert!(line.starts_with("DDNS MCP HTTP: http://")); + let address = line + .split("http://") + .nth(1) + .unwrap() + .split('/') + .next() + .unwrap(); + assert_eq!(get(address, "/api/config").0, 404); + assert_eq!(get(address, "/mcp").0, 405); +} + +#[test] +fn rejects_ambiguous_local_configs_and_insecure_bindings() { + let fixture = Fixture::new(); + for args in [ + vec!["web", "--host", "0.0.0.0"], + vec!["mcp", "--host", "127.0.0.1"], + vec!["mcp", "-c", "https://example.com/config"], + vec!["web", "-c", "first", "-c", "second"], + vec!["--interval", "5", "--dns", "debug"], + vec!["web", "--interval", "1441"], + vec!["web", "--port", "65536"], + ] { + let output = fixture.command().args(&args).output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}"); + } + let output = fixture + .command() + .args(["mcp"]) + .env("DDNS_CONFIG", "['one.json','two.json']") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn malformed_file_remains_repairable_on_explicit_web_command() { + let fixture = Fixture::new(); + fs::write(fixture.0.join("config.json"), "{ invalid JSON").unwrap(); + let mut command = fixture.command(); + command.args(["web", "-c", "config.json", "--port", "0"]); + let (_process, line) = Running::start(command); + let address = line + .split("http://") + .nth(1) + .unwrap() + .split('/') + .next() + .unwrap(); + let (status, config) = get(address, "/api/config"); + assert_eq!(status, 200); + assert!(config["validation_error"].is_string()); + assert_eq!(config["raw"], "{ invalid JSON"); +} + +#[cfg(target_os = "linux")] +#[test] +fn browser_launcher_does_not_block_http_serving() { + use std::os::unix::fs::PermissionsExt; + use std::time::Instant; + + let fixture = Fixture::new(); + fixture.write(json!({"providers":[]})); + // A local executable fixture avoids launching a user's real browser during tests. + let launcher = fixture.0.join("xdg-open"); + fs::write(&launcher, "#!/bin/sh\nsleep 2\n").unwrap(); + fs::set_permissions(&launcher, fs::Permissions::from_mode(0o700)).unwrap(); + let mut command = fixture.command(); + let path = format!( + "{}:{}", + fixture.0.display(), + std::env::var("PATH").unwrap_or_default() + ); + command + .env("PATH", path) + .args(["web", "--port", "0", "--open"]); + let (_process, line) = Running::start(command); + let address = line + .split("http://") + .nth(1) + .unwrap() + .split('/') + .next() + .unwrap(); + let started = Instant::now(); + assert_eq!(get(address, "/api/dashboard").0, 200); + assert!(started.elapsed() < Duration::from_secs(1)); +} diff --git a/tools/tests/test_v5_workflows.py b/tools/tests/test_v5_workflows.py index 04c45cc..849c934 100644 --- a/tools/tests/test_v5_workflows.py +++ b/tools/tests/test_v5_workflows.py @@ -37,6 +37,7 @@ RUST_ONLY_PATHS = ( "README.md", "README.en.md", ) +SHARED_RUST_INPUTS = ("web/**", "ddns/config/field-model.json") NATIVE_MATRIX = ( ("ubuntu-latest", "ddns-rs-linux-x64", "x86_64-unknown-linux-musl"), ("ubuntu-24.04-arm", "ddns-rs-linux-arm64", "aarch64-unknown-linux-musl"), @@ -102,7 +103,10 @@ class V5WorkflowTests(unittest.TestCase): trigger = block(workflow(name), event) self.assertEqual(inline_list(trigger, "branches"), branches) paths = tuple(re.findall(r"""(?m)^ - ["']([^"']+)["']$""", trigger)) - self.assertEqual(paths, RUST_ONLY_PATHS) + expected = RUST_ONLY_PATHS + if name != "build.yml": + expected = (RUST_ONLY_PATHS[0], *SHARED_RUST_INPUTS, *RUST_ONLY_PATHS[1:]) + self.assertEqual(paths, expected) self.assertIn(" paths-ignore:" if name == "build.yml" else " paths:", trigger) for name, events in (("build-docs.yml", ("push", "pull_request")), ("update-agents.yml", ("pull_request",))): for event in events: @@ -114,6 +118,8 @@ class V5WorkflowTests(unittest.TestCase): changes = block(text, "changes") case_paths = "|".join(path.replace("rust/**", "rust/*") for path in RUST_ONLY_PATHS) self.assertIn(case_paths + ")", changes) + for path in SHARED_RUST_INPUTS: + self.assertNotIn(path.replace("/**", "/*"), changes) self.assertIn('echo "pure_rust=false"', changes) self.assertIn("*)\n pure_rust=false", changes) self.assertIn("if: needs.changes.outputs.pure_rust == 'true'", block(text, "not-applicable")) @@ -193,13 +199,20 @@ class V5WorkflowTests(unittest.TestCase): expected = tuple((os, asset.removesuffix(".exe"), target) for os, asset, target in expected) self.assertEqual(matrix, expected) self.assertIn("fail-fast: false", binary) - self.assertIn("cargo test --manifest-path rust/Cargo.toml --locked", text) + self.assertIn("cargo test --manifest-path rust/Cargo.toml --locked", binary) self.assertIn( "cargo build --manifest-path rust/Cargo.toml --release --locked --target ${{ matrix.target }}", binary, ) self.assertIn('"$binary" --version', binary) self.assertIn("& $binary --version", binary) + for command in ("web", "mcp"): + self.assertIn(f'"$binary" {command} --help', binary) + self.assertIn( + f"& $binary {command} --help\n" + " if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }", + binary, + ) self.assertIn("--dns debug --no-cache", binary) self.assertIn("if-no-files-found: error", binary) self.assertIn('lto = "thin"', (ROOT / "rust/Cargo.toml").read_text(encoding="utf-8")) @@ -228,6 +241,24 @@ class V5WorkflowTests(unittest.TestCase): self.assertIn(" path: ddns-rs-oci.tar\n", container) self.assertIn(" if-no-files-found: error", container) + def test_container_embeds_only_explicit_public_assets_with_repository_layout(self) -> None: + text = (ROOT / "docker/rust.Dockerfile").read_text(encoding="utf-8") + builder = text.split("FROM alpine:", 1)[0] + self.assertIn("WORKDIR /build/rust\n", builder) + self.assertIn("target=/build/rust/target", builder) + copies = re.findall(r"(?m)^COPY (.+)$", builder) + self.assertEqual( + copies, + [ + "rust/Cargo.toml rust/Cargo.lock ./", + "rust/src ./src", + "web/index.html web/dashboard.js web/dashboard.css web/ddns.svg /build/web/", + "ddns/config/field-model.json /build/ddns/config/field-model.json", + ], + ) + for command in ("web", "mcp"): + self.assertIn(f"ddns-rs:test {command} --help", block(workflow("rust.yml"), "container")) + def test_rust_preparation_has_only_read_permissions_and_artifact_outputs(self) -> None: text = "\n".join( line for line in workflow("publish-rust.yml").splitlines() if not line.lstrip().startswith("#") @@ -239,7 +270,13 @@ class V5WorkflowTests(unittest.TestCase): self.assertEqual(inline_list(block(text, "push"), "branches"), ("v5",)) self.assertEqual( tuple(re.findall(r'(?m)^ - "([^"]+)"$', block(text, "push"))), - ("rust/Cargo.toml", "rust/Cargo.lock", ".github/workflows/publish-rust.yml"), + ( + "rust/Cargo.toml", + "rust/Cargo.lock", + *SHARED_RUST_INPUTS, + "docker/rust.Dockerfile", + ".github/workflows/publish-rust.yml", + ), ) self.assertIn("permissions:\n contents: read", text) self.assertIn(" needs: preflight\n", block(text, "binary"))