Feat/v5 web mcp (#745)

* feat(rust): add Web dashboard and MCP services

Share configuration, status and synchronization between Web and MCP; add authenticated HTTP, stdio lifecycle support, in-process scheduling and offline regression tests. Exercise native tests on the existing Windows/macOS/Linux CI matrix.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci(rust): allow manual cross-platform validation

Enable workflow_dispatch for feature-branch CI without requiring PR creation. Preserve matrices, permissions, publishing boundaries and trusted-branch cache policy.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci(rust): validate feature branch pushes

Run the existing Rust cross-platform matrix for feat/** pushes while retaining trusted-branch-only cache writes and unchanged release triggers.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci(rust): remove temporary branch trigger

The v5 pull request now exists and runs the complete native matrix, so restore the original trusted push branches while retaining Web/MCP paths and tests.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
New Future
2026-09-12 12:09:26 +08:00
committed by GitHub
co-authored by openhands
parent 93b79d2103
commit e9ad33e88b
35 changed files with 5964 additions and 161 deletions
+9
View File
@@ -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'
+4
View File
@@ -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"
+14
View File
@@ -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
+5 -1
View File
@@ -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
+3 -1
View File
@@ -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)。
## 为什么适合长期运行
+4 -2
View File
@@ -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
+49 -5
View File
@@ -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`。
+29 -2
View File
@@ -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):
+63 -8
View File
@@ -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.
+38 -2
View File
@@ -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):
+14
View File
@@ -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`,
+12
View File
@@ -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
+61 -4
View File
@@ -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 `<path>.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
+4
View File
@@ -58,6 +58,10 @@ impl Cache {
Ok(Some(cache))
}
pub(crate) fn modified(&self) -> Option<SystemTime> {
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))
+4 -2
View File
@@ -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 <FILE>... 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"));
}
+11
View File
@@ -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<String, Value>,
) -> Result<Vec<Config>> {
merge::expand_document(document)?
.iter()
.map(|document| Config::from_sources(&BTreeMap::new(), document, environment, false))
.collect()
}
fn config_paths(cli: &CliOptions, environment: &BTreeMap<String, Value>) -> Result<Vec<String>> {
if let Some(paths) = &cli.config_paths {
return Ok(paths.clone());
+415
View File
@@ -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<T> = std::result::Result<T, DashboardError>;
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<f64>,
failed: BTreeSet<usize>,
successful: BTreeSet<usize>,
cancelled: bool,
records: BTreeMap<(usize, String, String), (String, f64)>,
activities: Vec<Value>,
}
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<State>,
schedule: Arc<Schedule>,
}
impl DashboardService {
pub fn new(config_path: Option<PathBuf>, interval: u16) -> Result<Self> {
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<MutexGuard<'_, State>> {
self.state.lock().map_err(operation_error)
}
pub fn validate(&self, document: Value) -> Result<Value> {
validation::validate(document, &config::env::load())
}
fn parse(&self, content: &str) -> Result<Value> {
self.validate(config::file::parse(content).map_err(|_| {
invalid("configuration is not valid JSON or a supported legacy literal")
})?)
}
fn load_document(&self) -> Result<Value> {
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<Vec<Config>> {
config::from_document(self.load_document()?, &config::env::load())
.map_err(|_| invalid("invalid runtime configuration"))
}
pub fn config_state(&self) -> Result<Value> {
let _state = self.lock()?;
self.config_state_locked()
}
fn config_state_locked(&self) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
let mut state = self.lock()?;
self.dashboard_locked(&mut state)
}
fn dashboard_locked(&self, state: &mut State) -> Result<Value> {
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::<std::net::IpAddr>().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::<Vec<_>>()
);
Ok(result)
}
pub fn sync(&self, source: &str, cancelled: &(dyn Fn() -> bool + Sync)) -> Result<Value> {
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<Value> {
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<Self>) -> SchedulerHandle {
scheduler::start(self)
}
}
fn ensure_identity(state: &mut State, configs: &[Config]) -> Result<()> {
let identities = configs
.iter()
.map(Config::cache_identity)
.collect::<Vec<_>>();
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(())
}
+213
View File
@@ -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<Instant>,
last_run: Option<f64>,
last_error: Option<String>,
}
pub(super) struct Schedule {
state: Mutex<ScheduleState>,
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<bool>, 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<Schedule>,
thread: Option<JoinHandle<()>>,
}
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<DashboardService>) -> 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<DashboardService>) {
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();
}
}
+188
View File
@@ -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<PathBuf> {
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<Self> {
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::<String>();
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();
}
}
+474
View File
@@ -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<Value> = LazyLock::new(|| {
serde_json::from_str(include_str!("../../../ddns/config/field-model.json"))
.expect("canonical field model must be valid JSON")
});
static DOMAIN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
MODEL["rules"]["domainPattern"]
.as_str()
.expect("domain pattern"),
)
.expect("valid domain pattern")
});
static PROXY: LazyLock<Regex> = 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<u16> {
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<String>) -> DashboardError {
DashboardError {
status: 400,
code: "invalid_config",
message: message.into(),
}
}
fn structured(mut object: Map<String, Value>) -> Map<String, Value> {
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<String, Value>,
fallback: Option<&str>,
exclude: &[&str],
) -> Result<Value> {
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<Value> {
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<String, Value>) -> Result<Value> {
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::<ProviderId>()
.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<String, Value>) -> 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<u64>().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<Vec<String>> {
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::<std::collections::BTreeSet<_>>()
.len()
!= values.len()
{
return Err(invalid("duplicate list entries are not allowed"));
}
Ok(())
}
fn validate_source(source: &str) -> Result<()> {
if source.parse::<u64>().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::<ureq::http::Uri>()
.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(())
}
+653
View File
@@ -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<bool>,
}
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<String> {
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<Self, Response> {
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<Value, Response> {
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::<usize>()
.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<String, Response> {
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<u8>,
}
impl Response {
pub fn empty(status: u16) -> Self {
Self {
status,
headers: Vec::new(),
body: Vec::new(),
}
}
pub fn bytes(status: u16, body: Vec<u8>, 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<DashboardError> for Response {
fn from(error: DashboardError) -> Self {
Self::error(error.status, error.code, &error.message)
}
}
struct ServerState {
service: Arc<DashboardService>,
settings: HttpSettings,
web: bool,
launch: Mutex<Option<(String, Instant)>>,
}
pub struct HttpServer {
listener: TcpListener,
state: Arc<ServerState>,
}
impl HttpServer {
pub fn bind(service: Arc<DashboardService>, settings: HttpSettings, web: bool) -> Result<Self> {
// 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<SocketAddr> {
Ok(self.listener.local_addr()?)
}
pub fn launch_url(&self) -> Result<String> {
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::<String>();
*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<thread::JoinHandle<()>> = 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<AtomicUsize>);
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<TcpStream>) {
if request.body_read.get() {
return;
}
let Some(mut remaining) = request
.header("content-length")
.and_then(|length| length.parse::<usize>().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,
}
}
+320
View File
@@ -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<String>,
pub origins: Vec<String>,
}
impl HttpSettings {
pub fn resolve(
cli: &BTreeMap<String, Value>,
document: &Value,
environment: &BTreeMap<String, Value>,
) -> Result<Self> {
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<Self> {
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::<std::net::Ipv6Addr>().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::<u16>()
.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::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
fn authority(value: &str) -> Result<Authority> {
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::<Authority>()
.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::<u16>().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<String> {
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")));
}
}
+10
View File
@@ -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<Item = S>,
S: Into<OsString> + Clone,
{
let arguments: Vec<OsString> = arguments.into_iter().map(Into::into).collect();
if let Some(options) = server_command::parse(&arguments)? {
return server_command::run(options);
}
update::run(arguments)
}
+985
View File
@@ -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 = &params["_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<DashboardService>,
modern_only: bool,
legacy_initialized: bool,
legacy_ready: bool,
}
impl McpServer {
pub fn new(service: Arc<DashboardService>, 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<Value> {
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(&params["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<Option<Value>, 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<Value, String> {
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<String, Vec<Arc<AtomicBool>>>,
}
impl Pending {
fn register(&mut self, id: &Value) -> Arc<AtomicBool> {
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<AtomicBool>) {
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<Arc<AtomicBool>>,
}
// Drain overlong lines without allocating their full length, then resume at the next frame.
fn read_frame(reader: &mut impl BufRead) -> io::Result<Option<Vec<u8>>> {
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<R: BufRead + Send + 'static, W: Write>(
service: Arc<DashboardService>,
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::<Value>(&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<DashboardService>) -> 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<DashboardService> {
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::<Value>(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<Value> = 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<Vec<u8>>,
phase: u8,
started: PathBuf,
release: PathBuf,
}
impl Read for GatedInput {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
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<Value> = 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");
}
}
+272
View File
@@ -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<DashboardService>,
) -> 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<DashboardService>) -> 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::<f64>() 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<String> {
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());
}
}
+6 -2
View File
@@ -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(())
}
}
+10 -1
View File
@@ -125,6 +125,15 @@ pub fn build<'a>(
config: &Config,
client: &'a dyn HttpClient,
logger: Logger,
) -> Result<Box<dyn Provider + 'a>> {
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<Box<dyn Provider + 'a>> {
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)?)),
+333
View File
@@ -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<PathBuf>,
settings: BTreeMap<String, Value>,
interval: Option<u16>,
open: bool,
help: bool,
}
pub fn parse(arguments: &[OsString]) -> Result<Option<ServerOptions>> {
let args: Vec<&str> = match arguments
.iter()
.map(|arg| arg.to_str())
.collect::<Option<Vec<_>>>()
{
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<u16> {
value
.parse::<u16>()
.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<String, Value>,
) -> Result<bool> {
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<OsString> {
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:?}");
}
}
}
+211 -127
View File
@@ -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<String>,
pub records: Vec<UpdatedRecord>,
pub cancelled: bool,
}
pub fn update_configs(
configs: &[config::Config],
cancelled: &(dyn Fn() -> bool + Sync),
) -> Vec<UpdateOutcome> {
update_configs_with_output(configs, cancelled, false)
}
fn update_configs_with_output(
configs: &[config::Config],
cancelled: &(dyn Fn() -> bool + Sync),
emit_debug: bool,
) -> Vec<UpdateOutcome> {
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<String, serde_json::Value>,
logger: &Logger,
) -> Vec<String> {
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"));
+1 -1
View File
@@ -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"));
}
+377
View File
@@ -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<T: Send + Sync>() {}
send_sync::<DashboardService>();
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());
}
}
+609
View File
@@ -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<AtomicBool>,
thread: Option<JoinHandle<()>>,
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::<Vec<_>>(),
&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<String, String>,
body: Vec<u8>,
}
impl Response {
fn parse(output: Vec<u8>) -> 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(&notification, &[]);
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");
}
+283
View File
@@ -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<String>,
stderr: Option<thread::JoinHandle<String>>,
}
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::<Vec<_>>();
keys.sort();
assert_eq!(
keys,
[
"addresses",
"last_sync",
"message",
"providers",
"records",
"state"
]
);
assert_eq!(
serde_json::from_str::<Value>(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();
}
+240
View File
@@ -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));
}
+40 -3
View File
@@ -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"))