mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-21 07:35:16 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 为什么适合长期运行
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,985 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::thread;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::dashboard::DashboardService;
|
||||
use crate::error::Result;
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "2026-07-28";
|
||||
pub const LEGACY_PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
pub const PROTOCOL_VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion";
|
||||
pub const CLIENT_CAPABILITIES_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
|
||||
const CLIENT_INFO_KEY: &str = "io.modelcontextprotocol/clientInfo";
|
||||
const SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo";
|
||||
const INSTRUCTIONS: &str = "Use get_ddns_status to inspect local cached state. Use update_dns_records only with user approval to update configured DNS records.";
|
||||
const STATUS_FIELDS: [&str; 6] = [
|
||||
"state",
|
||||
"message",
|
||||
"last_sync",
|
||||
"addresses",
|
||||
"providers",
|
||||
"records",
|
||||
];
|
||||
const MAX_LINE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub fn error_response(id: Value, code: i64, message: &str) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
|
||||
}
|
||||
|
||||
fn request_id(message: &Value) -> Option<&Value> {
|
||||
message
|
||||
.get("id")
|
||||
.filter(|id| id.is_string() || id.as_i64().is_some() || id.as_u64().is_some())
|
||||
}
|
||||
|
||||
fn server_info() -> Value {
|
||||
json!({"name": "ddns", "version": env!("CARGO_PKG_VERSION")})
|
||||
}
|
||||
|
||||
fn complete(mut result: Value, modern: bool) -> Value {
|
||||
if modern {
|
||||
result["resultType"] = json!("complete");
|
||||
result["_meta"] = json!({SERVER_INFO_KEY: server_info()});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn valid_client_info(info: &Value) -> bool {
|
||||
info.is_object() && info["name"].is_string() && info["version"].is_string()
|
||||
}
|
||||
|
||||
fn validate_meta(params: &Value) -> std::result::Result<&Value, String> {
|
||||
let meta = ¶ms["_meta"];
|
||||
if !meta.is_object() {
|
||||
return Err("params._meta must be an object.".into());
|
||||
}
|
||||
if !meta[PROTOCOL_VERSION_KEY].is_string() {
|
||||
return Err(format!("{PROTOCOL_VERSION_KEY} is required."));
|
||||
}
|
||||
if !meta[CLIENT_CAPABILITIES_KEY].is_object() {
|
||||
return Err(format!("{CLIENT_CAPABILITIES_KEY} is required."));
|
||||
}
|
||||
if !meta[CLIENT_INFO_KEY].is_null() && !valid_client_info(&meta[CLIENT_INFO_KEY]) {
|
||||
return Err(format!(
|
||||
"{CLIENT_INFO_KEY} must contain string name and version fields."
|
||||
));
|
||||
}
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
fn tools() -> Value {
|
||||
json!([
|
||||
{
|
||||
"name": "get_ddns_status",
|
||||
"title": "Get DDNS status",
|
||||
"description": "Read configured providers, cached DNS records, addresses, and the latest local sync status.",
|
||||
"inputSchema": {"type": "object", "additionalProperties": false},
|
||||
"annotations": {"title": "Get DDNS status", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true}
|
||||
},
|
||||
{
|
||||
"name": "update_dns_records",
|
||||
"title": "Update DNS records",
|
||||
"description": "Run one complete DDNS synchronization for every record in the configured local file.",
|
||||
"inputSchema": {"type": "object", "additionalProperties": false},
|
||||
"annotations": {"title": "Update DNS records", "readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
fn tool_success(status: Value, modern: bool) -> Value {
|
||||
let status: Value = STATUS_FIELDS
|
||||
.into_iter()
|
||||
.map(|field| (field.to_owned(), status[field].clone()))
|
||||
.collect();
|
||||
complete(
|
||||
json!({"content": [{"type": "text", "text": status.to_string()}], "structuredContent": status, "isError": false}),
|
||||
modern,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct McpServer {
|
||||
service: Arc<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(¶ms["clientInfo"]) {
|
||||
Some("clientInfo must contain string name and version fields.")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(message) = error {
|
||||
return error_response(id, -32602, message);
|
||||
}
|
||||
self.legacy_initialized = true;
|
||||
self.legacy_ready = false;
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": {
|
||||
"protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {"tools": {}},
|
||||
"serverInfo": server_info(), "instructions": INSTRUCTIONS
|
||||
}})
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
&self,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
modern: bool,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
) -> std::result::Result<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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?)),
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¬ification, &[]);
|
||||
assert_eq!(response.status, 202);
|
||||
assert!(response.body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_mcp_has_no_dashboard_and_requires_bearer_auth() {
|
||||
let server = TestServer::new(false, Some("http-test-token"));
|
||||
assert_eq!(server.request("GET", "/", &[], "").status, 404);
|
||||
assert_eq!(server.request("GET", "/api/config", &[], "").status, 404);
|
||||
let request = message("tools/list", json!({}));
|
||||
let rejected = server.rpc(&request, &[]);
|
||||
assert_eq!(rejected.status, 401);
|
||||
assert!(rejected.headers.contains_key("www-authenticate"));
|
||||
assert_eq!(
|
||||
server
|
||||
.rpc(&request, &[("X-DDNS-Token", "http-test-token")])
|
||||
.status,
|
||||
401
|
||||
);
|
||||
assert_eq!(
|
||||
server
|
||||
.rpc(&request, &[("Authorization", "Bearer http-test-token")])
|
||||
.status,
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_validates_media_routing_headers_and_jsonrpc_before_dispatch() {
|
||||
let server = TestServer::new(false, None);
|
||||
let request = message("tools/call", json!({"name":"get_ddns_status"}));
|
||||
for (headers, status, code) in [
|
||||
(
|
||||
vec![("MCP-Protocol-Version", "2025-11-25")],
|
||||
400,
|
||||
Some(-32020),
|
||||
),
|
||||
(vec![("Mcp-Method", "tools/list")], 400, Some(-32020)),
|
||||
(vec![("Mcp-Name", "update_dns_records")], 400, Some(-32020)),
|
||||
(vec![("Accept", "application/json")], 406, None),
|
||||
(
|
||||
vec![("Accept", "application/json;q=0, text/event-stream")],
|
||||
406,
|
||||
None,
|
||||
),
|
||||
(vec![("Content-Type", "text/plain")], 415, None),
|
||||
] {
|
||||
let response = server.rpc(&request, &headers);
|
||||
assert_eq!(response.status, status);
|
||||
if let Some(code) = code {
|
||||
assert_eq!(response.json()["error"]["code"], code);
|
||||
}
|
||||
}
|
||||
let mut legacy = request.clone();
|
||||
legacy["params"]["_meta"][PROTOCOL_VERSION_KEY] = json!("2025-11-25");
|
||||
assert_eq!(server.rpc(&legacy, &[]).json()["error"]["code"], -32022);
|
||||
let mut invalid = request.clone();
|
||||
invalid["id"] = json!([]);
|
||||
let response = server.rpc(&invalid, &[("Mcp-Method", "mismatch")]);
|
||||
assert_eq!(response.status, 400);
|
||||
assert_eq!(response.json()["error"]["code"], -32600);
|
||||
assert!(response.json()["id"].is_null());
|
||||
assert_eq!(
|
||||
server
|
||||
.rpc(&message("resources/list", json!({})), &[])
|
||||
.status,
|
||||
404
|
||||
);
|
||||
assert_eq!(
|
||||
server
|
||||
.rpc(
|
||||
&message("resources/read", json!({"uri":"file:///status"})),
|
||||
&[]
|
||||
)
|
||||
.json()["error"]["code"],
|
||||
-32020
|
||||
);
|
||||
assert_eq!(
|
||||
server
|
||||
.rpc(
|
||||
&message("resources/read", json!({"uri":"file:///status"})),
|
||||
&[("Mcp-Name", "file:///status")]
|
||||
)
|
||||
.status,
|
||||
404
|
||||
);
|
||||
assert_eq!(
|
||||
server
|
||||
.rpc(&request, &[("Mcp-Name", "=?base64?Z2V0X2RkbnNfc3RhdHVz?=")])
|
||||
.status,
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_mcp_routing_headers_and_batch_requests() {
|
||||
let server = TestServer::new(false, None);
|
||||
let body = message("tools/list", json!({})).to_string();
|
||||
let response = server.raw(&format!("POST /mcp HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\nMCP-Protocol-Version: {PROTOCOL_VERSION}\r\nMCP-Protocol-Version: {PROTOCOL_VERSION}\r\nMcp-Method: tools/list\r\n\r\n{body}",server.address,body.len()));
|
||||
assert_eq!(response.status, 400);
|
||||
assert_eq!(response.json()["error"]["code"], -32020);
|
||||
let response = server.request(
|
||||
"POST",
|
||||
"/mcp",
|
||||
&[
|
||||
("Content-Type", "application/json"),
|
||||
("Accept", "application/json, text/event-stream"),
|
||||
],
|
||||
"[]",
|
||||
);
|
||||
assert_eq!(response.json()["error"]["code"], -32600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cors_is_exact_and_applies_to_transport_errors_only_on_mcp() {
|
||||
let server = TestServer::new(false, None);
|
||||
let request = message("tools/list", json!({}));
|
||||
for origin in ["https://evil.example", "null"] {
|
||||
assert_eq!(server.rpc(&request, &[("Origin", origin)]).status, 403);
|
||||
assert_eq!(
|
||||
server
|
||||
.request("GET", "/mcp", &[("Origin", origin)], "")
|
||||
.status,
|
||||
403
|
||||
);
|
||||
}
|
||||
let preflight = server.request(
|
||||
"OPTIONS",
|
||||
"/mcp",
|
||||
&[("Origin", "https://client.example")],
|
||||
"",
|
||||
);
|
||||
assert_eq!(preflight.status, 204);
|
||||
assert_eq!(
|
||||
preflight.headers["access-control-allow-origin"],
|
||||
"https://client.example"
|
||||
);
|
||||
assert_eq!(
|
||||
server
|
||||
.request("OPTIONS", "/mcp", &[("Origin", "https://evil.example")], "")
|
||||
.status,
|
||||
403
|
||||
);
|
||||
let response = server.rpc(
|
||||
&request,
|
||||
&[
|
||||
("Origin", "https://client.example"),
|
||||
("Mcp-Method", "wrong"),
|
||||
],
|
||||
);
|
||||
assert_eq!(response.status, 400);
|
||||
assert_eq!(
|
||||
response.headers["access-control-allow-origin"],
|
||||
"https://client.example"
|
||||
);
|
||||
let direct = format!("http://{}", server.address);
|
||||
assert_eq!(server.rpc(&request, &[("Origin", &direct)]).status, 200);
|
||||
let method = server.request("GET", "/mcp", &[], "");
|
||||
assert_eq!(method.status, 405);
|
||||
assert_eq!(method.headers["allow"], "POST, OPTIONS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_request_framing_and_rejects_auth_before_reading_body() {
|
||||
let server = TestServer::new(true, Some("http-test-token"));
|
||||
let started = Instant::now();
|
||||
let response = server.raw(&format!("POST /mcp HTTP/1.1\r\nHost: {}\r\nContent-Length: 100\r\nContent-Type: application/json\r\n\r\n",server.address));
|
||||
assert_eq!(response.status, 401);
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
for extra in [
|
||||
"Content-Length: 0\r\nContent-Length: 0\r\n",
|
||||
"Transfer-Encoding: chunked\r\n",
|
||||
"Host: duplicate\r\n",
|
||||
] {
|
||||
let response = server.raw(&format!(
|
||||
"POST /api/sync HTTP/1.1\r\nHost: {}\r\n{extra}\r\n",
|
||||
server.address
|
||||
));
|
||||
assert_eq!(response.status, 400);
|
||||
}
|
||||
let too_large = server.raw(&format!("POST /api/sync HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer http-test-token\r\nContent-Type: application/json\r\nContent-Length: 2097153\r\n\r\n",server.address));
|
||||
assert_eq!(too_large.status, 413);
|
||||
assert_eq!(server.raw("bad request\r\n\r\n").status, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_rejection_delivers_complete_response_for_buffered_body() {
|
||||
let server = TestServer::new(false, Some("http-test-token"));
|
||||
let body = "x".repeat(128 * 1024);
|
||||
let response = server.request(
|
||||
"POST",
|
||||
"/mcp",
|
||||
&[("Content-Type", "application/json")],
|
||||
&body,
|
||||
);
|
||||
assert_eq!(response.status, 401);
|
||||
assert_eq!(
|
||||
response.headers["content-length"],
|
||||
response.body.len().to_string()
|
||||
);
|
||||
assert_eq!(response.json()["error"]["code"], "invalid_token");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user