diff --git a/.dev.vars.example b/.dev.vars.example deleted file mode 100644 index 802b941..0000000 --- a/.dev.vars.example +++ /dev/null @@ -1,8 +0,0 @@ -ADMIN_USERNAME=change-this-local-username -ADMIN_PASSWORD=change-this-local-password -# Optional: configure Turnstile through Worker variables instead of the admin panel. -# TURNSTILE_SITE_KEY=your-site-key -# TURNSTILE_SECRET_KEY=your-secret-key -# Optional: enable Cloudflare usage statistics in the admin data workspace. -# CF_USAGE_ACCOUNT_ID=your-account-id -# CF_USAGE_API_TOKEN=token-with-account-analytics-read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8d5a95..074def6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,9 +49,23 @@ jobs: name: Quality checks needs: metadata runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 40 env: NODEFLARE_VERSION: ${{ needs.metadata.outputs.version }} + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_DB: nodeflare_smoke + POSTGRES_USER: nodeflare + POSTGRES_PASSWORD: SmokePostgresPassword123 + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U nodeflare -d nodeflare_smoke" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -59,7 +73,6 @@ jobs: - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: clippy, rustfmt - targets: wasm32-unknown-unknown - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 @@ -71,56 +84,165 @@ jobs: run: bun test --cwd frontend - name: Build frontend run: bun run build:frontend - - name: Check Rust formatting - run: cargo fmt --all -- --check + - name: Check backend formatting + run: cargo fmt --manifest-path backend/Cargo.toml -- --check - name: Check Agent formatting run: cargo fmt --manifest-path agent/Cargo.toml -- --check - - name: Lint Worker - run: cargo clippy --locked --all-targets -- -D warnings + - name: Lint backend + run: CARGO_TARGET_DIR=target/backend cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D warnings - name: Lint Agent - run: cargo clippy --locked --manifest-path agent/Cargo.toml --all-targets -- -D warnings - - name: Test Worker - run: cargo test --locked --lib + run: CARGO_TARGET_DIR=target/agent cargo clippy --locked --manifest-path agent/Cargo.toml --all-targets -- -D warnings + - name: Test backend + run: CARGO_TARGET_DIR=target/backend cargo test --locked --manifest-path backend/Cargo.toml - name: Test Agent - run: cargo test --locked --manifest-path agent/Cargo.toml - - name: Build Worker - run: NODEFLARE_ADMIN_READY=1 sh scripts/build-worker.sh - - name: Smoke test Worker and D1 + run: CARGO_TARGET_DIR=target/agent cargo test --locked --manifest-path agent/Cargo.toml + - name: Build backend + run: CARGO_TARGET_DIR=target/backend cargo build --locked --manifest-path backend/Cargo.toml + - name: Smoke test SQLite and PostgreSQL shell: bash + env: + POSTGRES_URL: postgres://nodeflare:SmokePostgresPassword123@127.0.0.1:5432/nodeflare_smoke run: | set -euo pipefail - state_dir="$RUNNER_TEMP/nodeflare-state" - log_file="$RUNNER_TEMP/nodeflare-dev.log" - export XDG_CONFIG_HOME="$RUNNER_TEMP/wrangler-config" - mkdir -p "$XDG_CONFIG_HOME" + sqlite_pid="" + postgres_pid="" + cleanup() { + if [ -n "$sqlite_pid" ]; then kill "$sqlite_pid" 2>/dev/null || true; fi + if [ -n "$postgres_pid" ]; then kill "$postgres_pid" 2>/dev/null || true; fi + } + trap cleanup EXIT - bunx wrangler d1 migrations apply DB --local --persist-to "$state_dir" - NODEFLARE_ADMIN_READY=1 NODEFLARE_WORKER_READY=1 \ - bunx wrangler dev --local --persist-to "$state_dir" --port 8787 \ - --var ADMIN_USERNAME:smoke --var ADMIN_PASSWORD:SmokePassword123 \ - >"$log_file" 2>&1 & - dev_pid=$! - trap 'kill "$dev_pid" 2>/dev/null || true' EXIT - - ready=0 - for _ in $(seq 1 30); do - if curl --fail --silent http://127.0.0.1:8787/api/bootstrap >/dev/null; then - ready=1 - break - fi - sleep 1 - done - if [ "$ready" != 1 ]; then + wait_ready() { + local url=$1 + local log_file=$2 + local process_id=$3 + for _ in $(seq 1 60); do + if curl --fail --silent "$url/api/bootstrap" >/dev/null; then return 0; fi + if ! kill -0 "$process_id" 2>/dev/null; then break; fi + sleep 1 + done cat "$log_file" + return 1 + } + + sqlite_dir="$RUNNER_TEMP/nodeflare-sqlite" + mkdir -p "$sqlite_dir" + sqlite_config="$sqlite_dir/config.toml" + printf '%s\n' \ + "database_url = \"sqlite://$sqlite_dir/nodeflare.db\"" \ + 'bind_addr = "127.0.0.1:18080"' \ + 'admin_username = "sqlite-smoke"' \ + 'admin_password = "SmokePassword123"' \ + 'turnstile_site_key = ""' \ + 'turnstile_secret_key = ""' \ + "frontend_dir = \"$GITHUB_WORKSPACE/frontend/dist\"" \ + "admin_frontend_dir = \"$GITHUB_WORKSPACE/frontend/admin-dist\"" \ + "agent_dir = \"$GITHUB_WORKSPACE/agent\"" > "$sqlite_config" + target/backend/debug/nodeflare-backend --config "$sqlite_config" > "$sqlite_dir/server.log" 2>&1 & + sqlite_pid=$! + wait_ready http://127.0.0.1:18080 "$sqlite_dir/server.log" "$sqlite_pid" + MONITOR_BASE_URL=http://127.0.0.1:18080 \ + MONITOR_ADMIN_USERNAME=sqlite-smoke \ + MONITOR_ADMIN_PASSWORD=SmokePassword123 \ + sh scripts/smoke-test.sh + kill "$sqlite_pid" + wait "$sqlite_pid" || true + sqlite_pid="" + + postgres_dir="$RUNNER_TEMP/nodeflare-postgres" + mkdir -p "$postgres_dir" + postgres_config="$postgres_dir/config.toml" + printf '%s\n' \ + "database_url = \"$POSTGRES_URL\"" \ + 'bind_addr = "127.0.0.1:18081"' \ + 'admin_username = "postgres-smoke"' \ + 'admin_password = "SmokePassword123"' \ + 'turnstile_site_key = ""' \ + 'turnstile_secret_key = ""' \ + "frontend_dir = \"$GITHUB_WORKSPACE/frontend/dist\"" \ + "admin_frontend_dir = \"$GITHUB_WORKSPACE/frontend/admin-dist\"" \ + "agent_dir = \"$GITHUB_WORKSPACE/agent\"" > "$postgres_config" + target/backend/debug/nodeflare-backend --config "$postgres_config" > "$postgres_dir/server.log" 2>&1 & + postgres_pid=$! + wait_ready http://127.0.0.1:18081 "$postgres_dir/server.log" "$postgres_pid" + MONITOR_BASE_URL=http://127.0.0.1:18081 \ + MONITOR_ADMIN_USERNAME=postgres-smoke \ + MONITOR_ADMIN_PASSWORD=SmokePassword123 \ + sh scripts/smoke-test.sh + + server: + name: Server (${{ matrix.platform }}) + needs: metadata + if: needs.metadata.outputs.release == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + attestations: write + contents: read + id-token: write + env: + CARGO_TARGET_DIR: target/server + NODEFLARE_VERSION: ${{ needs.metadata.outputs.version }} + strategy: + fail-fast: false + matrix: + include: + - platform: Linux x64 + target: x86_64-unknown-linux-musl + asset: nodeflare-server-linux-x64 + emulate: false + - platform: Linux ARM64 + target: aarch64-unknown-linux-musl + asset: nodeflare-server-linux-aarch64 + emulate: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target }} + - name: Install cross compiler + run: cargo install cross --version 0.2.5 --locked + - name: Build server + run: CROSS_CONFIG=agent/Cross.toml cross build --manifest-path backend/Cargo.toml --release --locked --target ${{ matrix.target }} + - name: Stage server binary + run: | + mkdir -p release + cp "target/server/${{ matrix.target }}/release/nodeflare-backend" "release/${{ matrix.asset }}" + chmod +x "release/${{ matrix.asset }}" + - name: Install ARM64 emulator + if: matrix.emulate + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends qemu-user-static + - name: Verify server version + shell: bash + run: | + if [ "${{ matrix.emulate }}" = "true" ]; then + version_output=$(qemu-aarch64-static "release/${{ matrix.asset }}" --version) + else + version_output=$("release/${{ matrix.asset }}" --version) + fi + actual_version="${version_output##* }" + if [ "$actual_version" != "${{ needs.metadata.outputs.version }}" ]; then + echo "Server version $actual_version does not match tag ${{ needs.metadata.outputs.version }}" >&2 exit 1 fi - - if ! MONITOR_BASE_URL=http://127.0.0.1:8787 \ - MONITOR_ADMIN_USERNAME=smoke MONITOR_ADMIN_PASSWORD=SmokePassword123 \ - sh scripts/smoke-test.sh; then - cat "$log_file" + - name: Verify static server binary + run: | + if readelf -l "release/${{ matrix.asset }}" | grep -q 'Requesting program interpreter'; then + echo "Linux server must be statically linked" >&2 exit 1 fi + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: release/${{ matrix.asset }} + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.asset }} + path: release/${{ matrix.asset }} + if-no-files-found: error agent: name: Agent (${{ matrix.platform }}) @@ -183,7 +305,7 @@ jobs: run: cargo install cross --version 0.2.5 --locked - name: Build Rust Agent with cross if: matrix.cross - run: CROSS_CONFIG=Cross.toml cross build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }} + run: CROSS_CONFIG=agent/Cross.toml cross build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }} - name: Build Rust Agent with Cargo if: matrix.cross == false run: cargo build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }} @@ -282,10 +404,10 @@ jobs: bun-version: 1.4.0 - name: Install dependencies run: bun install --frozen-lockfile - - name: Build public frontend - run: bun run --cwd frontend build:public + - name: Build public and admin frontends + run: bun run build:frontend - name: Package frontend - run: tar -czf "nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz" -C frontend/dist . + run: tar -czf "nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz" -C frontend dist admin-dist - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 with: subject-path: nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz @@ -298,7 +420,7 @@ jobs: release: name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [metadata, quality, agent, frontend] + needs: [metadata, quality, server, agent, frontend] runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..767e9fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# 构建产物 +/build/ +/target/ +/backend/target/ +/backend/build/ +/agent/target/ +/frontend/dist/ +/frontend/admin-dist/ +/release/ + +# 依赖 +/node_modules/ +/frontend/node_modules/ + +# 数据库文件 +/backend/*.db +/backend/*.db-shm +/backend/*.db-wal +*.db +*.db-shm +*.db-wal + +# 配置文件(包含密钥) +/backend/config.toml +config.toml + +# SQLx 编译时缓存 +/backend/.sqlx/ +.sqlx/ + +# 本地运行状态 +*.log + +# 旧版部署遗留的本地密钥,避免迁移时误提交 +.dev.vars +.dev.vars.* + +# 增量编译缓存。tsconfig 把它们指向 node_modules/.cache, +# 这条只是兜住散落在别处的。 +*.tsbuildinfo + +# 编辑器与操作系统 +/.idea/ +/.vscode/ +.DS_Store +Thumbs.db + +# 本机 agent 工具目录,不属于项目 +/.agents/ +/.claude/ +/.codex/ diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 2f5cd1b..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,918 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "js-sys", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "ctutils", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "typenum", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "nodeflare-worker" -version = "0.0.1" -dependencies = [ - "base64", - "futures-util", - "hmac", - "pbkdf2", - "serde", - "serde_json", - "sha2", - "worker", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "pbkdf2" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" -dependencies = [ - "digest", - "hmac", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "pin-project-lite", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "worker" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures-channel", - "futures-util", - "http", - "http-body", - "js-sys", - "matchit", - "pin-project", - "serde", - "serde-wasm-bindgen", - "serde_json", - "serde_urlencoded", - "strum", - "tokio", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "worker-macros", - "worker-sys", -] - -[[package]] -name = "worker-macros" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" -dependencies = [ - "async-trait", - "proc-macro2", - "quote", - "strum", - "syn 2.0.119", - "wasm-bindgen", - "wasm-bindgen-macro-support", - "worker-sys", -] - -[[package]] -name = "worker-sys" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 96f4441..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "nodeflare-worker" -version = "0.0.1" -edition = "2021" -license = "MIT" -description = "Rust/WASM server monitor for Cloudflare Workers" -repository = "https://github.com/imengying/NodeFlare" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -base64 = "0.23" -futures-util = { version = "0.3", default-features = false } -hmac = "0.13" -pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.11" -worker = { version = "0.8", features = ["d1"] } - -[profile.release] -codegen-units = 1 -lto = true -opt-level = "s" -strip = true diff --git a/Cross.toml b/Cross.toml deleted file mode 100644 index 65dca65..0000000 --- a/Cross.toml +++ /dev/null @@ -1,19 +0,0 @@ -[build.env] -passthrough = ["NODEFLARE_VERSION"] - -[target.aarch64-unknown-linux-musl] -runner = "qemu-user" - -# cross 0.2.5 自带的 FreeBSD 镜像 sysroot 是白名单拷贝的(docker/freebsd.sh 里逐个 -# `cp` 出来),漏了 libgeom,而 sysinfo 的 FreeBSD FFI 无条件 `#[link(name = "geom")]` -# 且只受 disk/system feature 控制 —— 这两个 feature agent 都要,绕不开,链接期就 -# `cannot find -lgeom`。上游 main 分支已改成整个 `/lib/*` 全拷贝并自动补 `.so` 软链, -# sysroot 里 libgeom.so / libbsdxml.so / libsbuf.so 齐全(已核对镜像层内容)。 -# -# 只覆盖这一个 target,其余 target 仍走 CLI 自己推导的 :0.2.5 镜像。 -# Cross.toml 指定 image 会直接短路 image_name() 的版本推导,不存在 CLI 与镜像的版本校验。 -# 按 digest 钉死,和本仓库所有 action 钉 SHA 的做法一致;这份 digest 是 :main 的 -# multi-arch index,docker 会自己挑 linux/amd64。 -# 换来的副作用:sysroot 从 FreeBSD 12 变成 13,产物的最低运行版本相应提高到 13。 -[target.x86_64-unknown-freebsd] -image = "ghcr.io/cross-rs/x86_64-unknown-freebsd@sha256:d1ecbeb055803f5316607acce26c8af2b8609a04e38a06ba7aed84f9df80042e" diff --git a/README.md b/README.md index 5becaca..cf587d5 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,146 @@ # NodeFlare -基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控,支持 Linux、Windows、macOS 和 FreeBSD Agent。 +NodeFlare 是一个自部署的服务器监控面板。服务端使用 Rust/Axum,通过 WebSocket 接收 Agent 指标并实时推送到浏览器,数据库可选 SQLite 或 PostgreSQL。 -## 界面预览 +当前版本不依赖 Cloudflare Workers、D1 或 Durable Objects。Cloudflare Turnstile 仅作为可选的人机验证功能。 -![仪表盘](docs/dashboard-light.png) +## 快速开始 -![主题商店](docs/theme-store-light.png) +需要稳定版 Rust、Bun 1.4+,以及常见的 C/C++ 构建工具。 -## 能力 - -- 采集 CPU、GPU、负载、内存、磁盘、网络、连接数、进程和系统信息 -- TCP/ICMP 延迟检测,可按节点分配测试点和周期 -- NodeFlare Glass 风格总览、节点卡片、搜索/分组筛选、响应式深浅主题 -- 节点详情与历史图表,支持实时、1/4/24/168/720 小时范围 -- WebSocket 实时刷新与 Agent WSS 上报 -- 计费、到期、流量和多币种资产统计,Worker 每日更新汇率 -- 用户名密码登录、Cloudflare Turnstile 和节点隐藏 -- 节点管理、批量删除、拖拽排序和 Agent 在线配置 -- Telegram 通知、失败重试、资源/流量/离线/到期提醒和数据维护 -- 内置 NodeFlare Glass 主题,并提供远程主题商店 -- 远程主题支持 GitHub 仓库地址(默认 `main` 分支,也可用 `tree` 指定分支或子目录),Worker 代理 `index.html` 与 `assets/` -- Rust Agent 支持 Linux x64/ARM64、Windows x64、macOS ARM64 和 FreeBSD x64(13 及以上),可按节点自动更新 - -## 部署到 Cloudflare - -1. Fork 本仓库并打开 [Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create),选择 **Import a repository** 后连接仓库。 -2. 在构建设置中将 **Build command(构建命令)清空**。Cloudflare 可能自动识别为 `bun run build`,需要手动删除;如果控制台不允许留空,可填写 `true`。 -3. 将 **Deploy command(部署命令)** 修改为 `bun run deploy`。该命令会按需构建前端和 Worker、应用 D1 迁移并完成部署,无需单独执行构建命令;同时配置 `bun run build` 会导致前端重复构建。 -4. 在 **高级设置** 中填写下表变量后部署。 - -| 变量 | 必填/可选 | 说明 | -| --- | ---: | --- | -| `ADMIN_USERNAME` | 必填 | 管理员登录用户名 | -| `ADMIN_PASSWORD` | 必填 | 初始管理员密码,勾选“加密”;后台保存密码哈希后可删除该变量 | -| `SITE_NAME` | 可选 | 站点名称,未设置时使用 `NodeFlare` | -| `TURNSTILE_SITE_KEY` | 可选 | Turnstile Site Key,无需加密;也可在后台设置 | -| `TURNSTILE_SECRET_KEY` | 可选 | Turnstile Secret Key,勾选“加密”;也可在后台设置 | -| `OFFLINE_THRESHOLD_SECONDS` | 可选 | 离线判定秒数,未设置时使用 180,范围 30-3600 | -| `HISTORY_RETENTION_DAYS` | 可选 | 历史保留天数,未设置时使用 30,范围 1-30 | -| `CF_USAGE_ACCOUNT_ID` | 可选 | Cloudflare 用量查询的账户 ID,不设置则不启用用量查询 | -| `CF_USAGE_API_TOKEN` | 可选 | 用量查询 Token,勾选“加密”;需要 Account Analytics: Read,并授权对应账户 | - -## Agent 卸载 - -Linux: - -```sh -curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh | sudo sh -s -- --uninstall +```bash +cp backend/config.example.toml backend/config.toml +$EDITOR backend/config.toml +./start.sh ``` -FreeBSD: +必须先替换示例中的 `admin_password` 占位值,否则服务会拒绝启动。`start.sh` 会安装尚未安装的前端依赖、构建公开页和管理页、编译后端,然后监听配置的地址。 -```sh -fetch -qo - https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install-freebsd.sh | sudo sh -s -- --uninstall +默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。首次登录后可在“登录与安全”中启用 TOTP 两步验证。 + +## 配置 + +配置项是扁平 TOML 字段,路径均相对于配置文件所在目录解析。完整模板见 [`backend/config.example.toml`](backend/config.example.toml)。 + +SQLite 示例: + +```toml +database_url = "sqlite://nodeflare.db" +bind_addr = "127.0.0.1:8080" +admin_username = "admin" +admin_password = "replace-with-a-long-random-password" ``` -## API +PostgreSQL 示例: -| 方法 | 路径 | 用途 | -| --- | --- | --- | -| `GET` | `/api/bootstrap` | 前台配置、节点、汇率和访问状态 | -| `GET` | `/api/config` | 公开站点配置 | -| `GET` | `/api/exchange-rates` | D1 中的 CNY 基准汇率快照 | -| `GET` | `/api/servers` | 公开节点和最新指标 | -| `GET` | `/api/history/:id?hours=24` | 节点历史 | -| `GET` | `/api/latency/:id?hours=24` | 节点延迟任务历史 | -| `GET` | `/api/ws` | 实时 WebSocket | -| `GET` | `/api/agent/ws` | Agent 配置同步与指标上报 WebSocket,节点 Bearer Token | -| `POST` | `/api/live/wake` | 总览页连上实时通道后批量唤醒可见节点的实时推送 | -| `POST` | `/api/admin/login` | 管理登录 | -| `POST` | `/api/admin/logout` | 退出登录(清除会话 Cookie) | -| `GET/POST` | `/api/admin/servers` | 管理节点 | -| `GET` | `/api/admin/servers/:id/token` | 读取节点 Agent Token | -| `GET/POST` | `/api/admin/latency-tasks` | 查询或创建延迟任务 | -| `PATCH/DELETE` | `/api/admin/latency-tasks/:id` | 编辑或删除延迟任务 | -| `GET/POST` | `/api/admin/alert-rules` | 查询或创建资源告警规则 | -| `PATCH/DELETE` | `/api/admin/alert-rules/:id` | 编辑或删除资源告警规则 | -| `GET/PUT` | `/api/admin/telegram` | 读写 Telegram 通知配置 | -| `POST` | `/api/admin/telegram/test` | 发送 Telegram 测试消息 | -| `PATCH/DELETE` | `/api/admin/servers/:id` | 编辑或删除节点 | -| `PATCH` | `/api/admin/servers/order` | 更新全部节点顺序 | -| `DELETE` | `/api/admin/servers` | 批量删除节点 | -| `GET/PATCH` | `/api/admin/settings` | 读写站点与展示设置 | -| `GET/POST` | `/api/admin/themes` | 查询或添加主题 | -| `POST` | `/api/admin/themes/:id/activate` | 启用主题 | -| `POST` | `/api/admin/themes/:id/preview` | 创建短时主题预览链接 | -| `DELETE` | `/api/admin/themes/:id` | 删除主题 | -| `GET` | `/api/admin/theme-settings` | 读取当前前端提供的主题设置描述 | -| `POST` | `/api/turnstile/verify` | 校验全站 Turnstile 令牌 | -| `GET` | `/api/admin/database` | 查询 D1 节点、历史和数据库统计 | -| `GET` | `/api/admin/cloudflare-usage` | 查询 UTC 今日/昨日的 D1、Workers 与 Durable Objects 用量 | -| `POST` | `/api/admin/exchange-rates/refresh` | 立即拉取并更新 D1 汇率快照 | -| `DELETE` | `/api/admin/history` | 清理历史指标 | +```toml +database_url = "postgres://nodeflare:password@127.0.0.1/nodeflare" +bind_addr = "127.0.0.1:8080" +admin_username = "admin" +admin_password = "replace-with-a-long-random-password" +``` -## 主题 +两种数据库都会在启动时自动执行对应迁移。修改 `database_url` 只会初始化新数据库,不会自动搬运另一数据库中的现有数据。 -GitHub 仓库地址对应的仓库需提供 `index.html` 和 `assets/`(默认 `main` 分支,可用 `tree` 地址指定分支或子目录),并使用 NodeFlare 公开 API 读取数据。可选的 `theme.json` 可声明后台显示的主题设置项。启用第三方主题前请确认来源可信。 +Turnstile 默认关闭。需要时同时填写 `turnstile_site_key` 和 `turnstile_secret_key`,再从管理页开启公开面板或管理员登录保护;两项留空即可完全禁用。 -## 鸣谢 +## 部署 Agent -感谢 [CF-Server-Monitor](https://github.com/huilang-me/CF-Server-Monitor) 项目提供的思路。 +在管理页创建节点后,复制对应平台的安装命令。Agent 的服务地址应是浏览器能够访问的 NodeFlare HTTPS 地址,例如: + +```bash +curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh \ + | sudo sh -s -- -e 'https://monitor.example.com' -t 'your-agent-token' +``` + +Agent 通过 `/api/agent/ws` 建立 WebSocket 连接。除 localhost 调试外,安装脚本要求 HTTPS。 + +## 开发与构建 + +```bash +# 构建静态前端并启动 debug 后端 +./dev.sh + +# 单独运行 Vite;API 和 WebSocket 会代理到 127.0.0.1:8080 +bun run dev:frontend + +# 构建公开页、管理页和 release 后端 +bun run build + +# 构建 Agent +bun run build:agent +``` + +运行测试: + +```bash +cargo test --locked --manifest-path backend/Cargo.toml +cargo test --locked --manifest-path agent/Cargo.toml +bun test --cwd frontend +``` + +烟雾测试需要一个已经运行的 NodeFlare 实例,以及 `curl`、`jq`、Bun 和 Node.js: + +```bash +MONITOR_BASE_URL=http://127.0.0.1:8080 \ +MONITOR_ADMIN_USERNAME=admin \ +MONITOR_ADMIN_PASSWORD='your-password' \ +sh scripts/smoke-test.sh +``` + +## HTTPS 与反向代理 + +生产环境建议让 NodeFlare 只监听回环地址,由 Nginx、Caddy 或同类反向代理终止 TLS。反向代理必须保留 Host、协议和 WebSocket Upgrade 头。Nginx 示例: + +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl; + server_name monitor.example.com; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } +} +``` + +`X-Forwarded-Proto: https` 也用于给管理员会话 Cookie 添加 `Secure`。NodeFlare 自身会为 API 和静态资源添加 CSP、禁止 iframe、MIME 嗅探限制等安全响应头。 + +## 备份 + +SQLite 默认文件是 `backend/nodeflare.db`。可在服务运行时使用 SQLite 的一致性备份命令: + +```bash +sqlite3 backend/nodeflare.db ".backup '/var/backups/nodeflare.db'" +``` + +PostgreSQL 使用标准工具: + +```bash +pg_dump --format=custom --file=/var/backups/nodeflare.dump \ + 'postgres://nodeflare:password@127.0.0.1/nodeflare' +``` + +恢复前请停止 NodeFlare,并先在独立环境验证备份。配置文件包含管理员初始凭据和可选的 Turnstile 密钥,也应通过权限受控的方式单独备份。 + +## 架构 + +- 后端:Rust、Axum、SQLx +- 数据库:SQLite 或 PostgreSQL +- 前端:React、TypeScript、Vite +- 实时通信:浏览器 `/api/ws`,Agent `/api/agent/ws` +- 认证:数据库中的不透明会话令牌,可选 TOTP 两步验证 ## License diff --git a/agent/Cross.toml b/agent/Cross.toml new file mode 100644 index 0000000..d88f3d6 --- /dev/null +++ b/agent/Cross.toml @@ -0,0 +1,11 @@ +[build.env] +passthrough = ["NODEFLARE_VERSION"] + +[target.aarch64-unknown-linux-musl] +runner = "qemu-user" + +# cross 0.2.5 自带的 FreeBSD 镜像 sysroot 漏了 libgeom。上游 main 镜像已 +# 补齐完整 /lib 及共享库软链,因此为 FreeBSD Agent 固定使用该镜像摘要。 +# 该镜像以 FreeBSD 13 为 sysroot,所以产物最低支持 FreeBSD 13。 +[target.x86_64-unknown-freebsd] +image = "ghcr.io/cross-rs/x86_64-unknown-freebsd@sha256:d1ecbeb055803f5316607acce26c8af2b8609a04e38a06ba7aed84f9df80042e" diff --git a/agent/agent.sh b/agent/agent.sh index 86444ec..6f18403 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -4,7 +4,6 @@ set -eu SERVICE_NAME="nodeflare" INSTALL_DIR="/opt/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" -TOKEN_FILE="$INSTALL_DIR/token" SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" OPENRC_FILE="/etc/init.d/$SERVICE_NAME" @@ -13,12 +12,12 @@ usage() { NodeFlare Agent 安装脚本 用法: - agent.sh -e -t [-i <上报间隔>] [-m <下载加速前缀>] + agent.sh -e -t [-i <上报间隔>] [-m <下载加速前缀>] agent.sh --status agent.sh --uninstall 参数: - -e NodeFlare Worker 地址(必填) + -e NodeFlare 服务地址(必填) -t 后台生成的 Agent Token(必填,请勿泄露) -i 初始上报间隔,15-3600 秒(默认 60) -m GitHub 下载加速前缀(可选,仅作用于 Release 下载; @@ -112,12 +111,12 @@ install_agent() { [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } endpoint=${endpoint%/} [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制" - safe_value "$token" && safe_value "$endpoint" || fail "Worker 地址或 Agent Token 格式无效" + safe_value "$token" && safe_value "$endpoint" || fail "服务地址或 Agent Token 格式无效" case "$endpoint" in https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;; - *) fail "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;; + *) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;; esac - case "$endpoint" in *@*) fail "Worker 地址不能包含用户信息" ;; esac + case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac [ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间" mirror=${mirror%/} @@ -200,9 +199,6 @@ install_agent() { esac mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM - (umask 077; printf '%s\n' "$token" > "$TOKEN_FILE") - chmod 600 "$TOKEN_FILE" - token="" log "正在配置并启动 $init_system 服务" if [ "$init_system" = "systemd" ]; then printf '%s\n' \ @@ -213,7 +209,7 @@ install_agent() { '' \ '[Service]' \ 'Type=simple' \ - "ExecStart=$AGENT_FILE -e $endpoint --token-file $TOKEN_FILE -i $interval" \ + "ExecStart=$AGENT_FILE -e $endpoint -t $token -i $interval" \ 'Restart=always' \ 'RestartSec=10' \ 'NoNewPrivileges=true' \ @@ -236,7 +232,7 @@ install_agent() { '#!/sbin/openrc-run' \ "name=\"$SERVICE_NAME\"" \ "command=\"$AGENT_FILE\"" \ - "command_args=\"-e $endpoint --token-file $TOKEN_FILE -i $interval\"" \ + "command_args=\"-e $endpoint -t $token -i $interval\"" \ "command_user=\"root\"" \ "supervisor=\"supervise-daemon\"" \ "respawn_delay=10" \ diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh index 1a324a4..d6f3ebd 100755 --- a/agent/install-freebsd.sh +++ b/agent/install-freebsd.sh @@ -4,7 +4,6 @@ set -eu SERVICE_NAME="nodeflare" INSTALL_DIR="/usr/local/libexec/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" -TOKEN_FILE="$INSTALL_DIR/token" SERVICE_FILE="/usr/local/etc/rc.d/$SERVICE_NAME" log() { @@ -21,7 +20,7 @@ usage() { NodeFlare Agent FreeBSD 安装脚本 用法: - install-freebsd.sh -e -t [-i <上报间隔>] [-m <下载加速前缀>] + install-freebsd.sh -e -t [-i <上报间隔>] [-m <下载加速前缀>] install-freebsd.sh --status install-freebsd.sh --uninstall @@ -64,7 +63,7 @@ if [ "${1:-}" = "--uninstall" ]; then log "正在停止并移除 NodeFlare Agent" service "$SERVICE_NAME" stop 2>/dev/null || true sysrc -x "${SERVICE_NAME}_enable" >/dev/null 2>&1 || true - rm -f "$SERVICE_FILE" "$AGENT_FILE" "$TOKEN_FILE" + rm -f "$SERVICE_FILE" "$AGENT_FILE" rmdir "$INSTALL_DIR" 2>/dev/null || true echo "NodeFlare Agent 已卸载" exit 0 @@ -117,12 +116,12 @@ done [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } endpoint=${endpoint%/} [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制" -safe_value "$token" && safe_value "$endpoint" || fail "Worker 地址或 Agent Token 格式无效" +safe_value "$token" && safe_value "$endpoint" || fail "服务地址或 Agent Token 格式无效" case "$endpoint" in https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;; - *) fail "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;; + *) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;; esac -case "$endpoint" in *@*) fail "Worker 地址不能包含用户信息" ;; esac +case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac [ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间" mirror=${mirror%/} @@ -186,9 +185,6 @@ log "正在配置并启动 FreeBSD rc.d 服务" service "$SERVICE_NAME" stop 2>/dev/null || true mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM -(umask 077; printf '%s\n' "$token" > "$TOKEN_FILE") -chmod 600 "$TOKEN_FILE" -token="" cat > "$SERVICE_FILE" < -t [-i <上报间隔>] [-m <下载加速前缀>] + install-macos.sh -e -t [-i <上报间隔>] [-m <下载加速前缀>] install-macos.sh --status install-macos.sh --uninstall @@ -40,7 +39,7 @@ if [ "${1:-}" = "--uninstall" ]; then [ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载" log "正在停止并移除 NodeFlare Agent" launchctl bootout system "$PLIST_FILE" 2>/dev/null || true - rm -f "$PLIST_FILE" "$AGENT_FILE" "$TOKEN_FILE" + rm -f "$PLIST_FILE" "$AGENT_FILE" rmdir "$INSTALL_DIR" 2>/dev/null || true echo "NodeFlare Agent 已卸载" exit 0 @@ -86,12 +85,12 @@ done [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } endpoint=${endpoint%/} [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制" -safe_value "$token" && safe_value "$endpoint" || fail "Worker 地址或 Agent Token 格式无效" +safe_value "$token" && safe_value "$endpoint" || fail "服务地址或 Agent Token 格式无效" case "$endpoint" in https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;; - *) fail "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;; + *) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;; esac -case "$endpoint" in *@*) fail "Worker 地址不能包含用户信息" ;; esac +case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac [ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间" mirror=${mirror%/} @@ -159,15 +158,12 @@ log "正在配置并启动 macOS LaunchDaemon 服务" launchctl bootout system "$PLIST_FILE" 2>/dev/null || true mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM -(umask 077; printf '%s\n' "$token" > "$TOKEN_FILE") -chmod 600 "$TOKEN_FILE" -token="" cat > "$PLIST_FILE" < Label$LABEL -ProgramArguments$AGENT_FILE-e$endpoint--token-file$TOKEN_FILE-i$interval +ProgramArguments$AGENT_FILE-e$endpoint-t$token-i$interval KeepAliveRunAtLoad StandardOutPath/var/log/nodeflare-agent.log StandardErrorPath/var/log/nodeflare-agent.log diff --git a/agent/install.ps1 b/agent/install.ps1 index 3f7f390..0a52fde 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -12,7 +12,6 @@ $ErrorActionPreference = "Stop" $TaskName = "NodeFlare Agent" $InstallDir = Join-Path $env:ProgramData "NodeFlare" $AgentFile = Join-Path $InstallDir "nodeflare-agent.exe" -$TokenFile = Join-Path $InstallDir "token" function Write-Step([string]$Message) { Write-Host "[NodeFlare] $Message" @@ -45,7 +44,7 @@ function Assert-Endpoint([string]$Value) { -not [string]::IsNullOrEmpty($Parsed.Query) -or -not [string]::IsNullOrEmpty($Parsed.Fragment) ) { - Write-InstallError "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" + Write-InstallError "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" } } @@ -148,10 +147,7 @@ try { Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue } -[IO.File]::WriteAllText($TokenFile, "$Token`n", [Text.UTF8Encoding]::new($false)) -& icacls.exe $TokenFile /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' | Out-Null -$Token = "" -$TaskArguments = "-e `"$Endpoint`" --token-file `"$TokenFile`" -i $Interval" +$TaskArguments = "-e `"$Endpoint`" -t `"$Token`" -i $Interval" Write-Step "正在注册并启动 Windows 计划任务" $TaskAction = New-ScheduledTaskAction -Execute $AgentFile -Argument $TaskArguments $Trigger = New-ScheduledTaskTrigger -AtStartup diff --git a/agent/src/main.rs b/agent/src/main.rs index 3cd672e..2222ffe 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -55,7 +55,7 @@ const LIVE_RECONNECT_DELAY: Duration = Duration::from_secs(3); const LIVE_QUEUE_CAPACITY: usize = 720; const MAX_PENDING_SPOOL_BYTES: u64 = 64 * 1024 * 1024; const MAX_PENDING_SPOOL_LINE_BYTES: usize = 1024 * 1024; -// D1 persistence can take a few hundred milliseconds; allow the ACK to arrive +// Database persistence can take a few hundred milliseconds; allow the ACK to arrive // before continuing with the configured live interval. const LIVE_ACK_READ_TIMEOUT: Duration = Duration::from_secs(5); const LIVE_HINT_READ_TIMEOUT: Duration = Duration::from_millis(10); @@ -93,9 +93,9 @@ struct CliOptions { /// NodeFlare endpoint #[arg(short = 'e', value_name = "URL")] endpoint: String, - /// Path to a file containing the Agent token - #[arg(long, value_name = "PATH")] - token_file: PathBuf, + /// Agent token + #[arg(short = 't', value_name = "TOKEN")] + token: String, /// Initial report interval in seconds (15-3600) #[arg(short = 'i', value_name = "SECONDS", default_value_t = 60)] interval: u64, @@ -1705,18 +1705,7 @@ fn runtime_config(options: &CliOptions) -> Result { if !(15..=3600).contains(&interval) { return Err("interval must be between 15 and 3600 seconds".into()); } - let mut token_file = fs::File::open(&options.token_file)?; - let metadata = token_file.metadata()?; - if !metadata.is_file() || metadata.len() > 513 { - return Err("token file is invalid".into()); - } - #[cfg(unix)] - if metadata.permissions().mode() & 0o077 != 0 { - return Err("token file must not be accessible by group or other users".into()); - } - let mut token = String::new(); - token_file.read_to_string(&mut token)?; - let token = token.trim().to_string(); + let token = options.token.clone(); let endpoint = options.endpoint.clone(); if token.is_empty() || token.len() > 512 || token.chars().any(char::is_whitespace) { return Err("token is invalid".into()); @@ -1796,7 +1785,7 @@ struct LiveAck { #[serde(rename = "persistedThroughTs")] persisted_through_ts: i64, #[serde(rename = "nextD1WriteAfterMs")] - next_d1_write_after_ms: u64, + next_persist_after_ms: u64, #[serde(rename = "nextWssReportAfterMs")] next_wss_report_after_ms: u64, #[serde(rename = "realtimeHint")] @@ -1812,19 +1801,105 @@ struct LiveConfigMessage { config: RemoteConfig, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RemoteTaskMessage { + #[serde(rename = "type")] + message_type: String, + task_id: String, + command: String, + script: String, +} + +#[derive(Debug, Serialize)] +struct TaskResultMessage { + #[serde(rename = "type")] + message_type: String, + task_id: String, + status: String, + result: String, + exit_code: Option, +} + +fn execute_remote_task(task: &RemoteTaskMessage) -> TaskResultMessage { + let task_id = task.task_id.clone(); + + // 优先执行 script,否则执行 command + let (shell, args) = if !task.script.is_empty() { + #[cfg(unix)] + { + ("sh", vec!["-c", task.script.as_str()]) + } + #[cfg(target_os = "windows")] + { + ("cmd.exe", vec!["/C", task.script.as_str()]) + } + } else if !task.command.is_empty() { + #[cfg(unix)] + { + ("sh", vec!["-c", task.command.as_str()]) + } + #[cfg(target_os = "windows")] + { + ("cmd.exe", vec!["/C", task.command.as_str()]) + } + } else { + return TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: "failed".to_string(), + result: "No command or script provided".to_string(), + exit_code: Some(-1), + }; + }; + + match Command::new(shell).args(&args).output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = if stderr.is_empty() { + stdout.to_string() + } else { + format!("{}\n{}", stdout.trim(), stderr.trim()) + }; + + TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: if output.status.success() { + "success" + } else { + "failed" + } + .to_string(), + result: combined.trim().to_string(), + exit_code: output.status.code().map(|c| c as i64), + } + } + Err(error) => TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: "failed".to_string(), + result: format!("Execution error: {}", error), + exit_code: Some(-1), + }, + } +} + fn ack_wss_interval(ack: &LiveAck) -> Duration { Duration::from_millis(ack.next_wss_report_after_ms) .clamp(Duration::from_secs(1), Duration::from_secs(3600)) } fn ack_d1_interval(ack: &LiveAck) -> Duration { - Duration::from_millis(ack.next_d1_write_after_ms) + Duration::from_millis(ack.next_persist_after_ms) .clamp(Duration::from_secs(1), Duration::from_secs(3600)) } enum LiveRead { Ack(LiveAck), Config(RemoteConfig), + RemoteTask(RemoteTaskMessage), Closed, Pending, } @@ -1854,6 +1929,15 @@ fn read_live_ack(socket: &mut LiveSocket) -> Result { } Ok(LiveRead::Config(message.config)) } + Some("remote_task") => { + let Ok(task) = serde_json::from_value::(value) else { + return Ok(LiveRead::Pending); + }; + if task.message_type != "remote_task" || task.task_id.is_empty() { + return Ok(LiveRead::Pending); + } + Ok(LiveRead::RemoteTask(task)) + } _ => Ok(LiveRead::Pending), } } @@ -1892,6 +1976,13 @@ fn wait_for_live_ack( *target = Some(config); } } + LiveRead::RemoteTask(task) => { + // 执行任务并发送结果 + let result = execute_remote_task(&task); + if let Ok(payload) = serde_json::to_string(&result) { + let _ = socket.send(Message::Text(payload.into())); + } + } LiveRead::Closed => return Ok(LiveRead::Closed), LiveRead::Ack(_) | LiveRead::Pending => {} } @@ -2041,6 +2132,12 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { *target = Some(config); } } + Ok(LiveRead::RemoteTask(task)) => { + let result = execute_remote_task(&task); + if let Ok(payload) = serde_json::to_string(&result) { + let _ = connected.send(Message::Text(payload.into())); + } + } Ok(LiveRead::Pending) => {} Err(error) => { eprintln!("live hint read failed: {error}"); @@ -2115,6 +2212,9 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { } drop_socket = true; } + Ok(LiveRead::RemoteTask(_task)) => { + // 在 wait_for_live_ack 中已处理,这里不应该出现 + } Ok(LiveRead::Pending) => { eprintln!("live ACK timed out"); drop_socket = true; @@ -2228,15 +2328,12 @@ fn prune_report_samples(samples: &mut Vec, now: i64) { } } -fn pending_spool_path(token_file: &Path) -> PathBuf { - let name = token_file - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("nodeflare-token"); - token_file +fn pending_spool_path() -> Result { + let executable = env::current_exe()?; + let directory = executable .parent() - .unwrap_or_else(|| Path::new(".")) - .join(format!(".{name}.pending.jsonl")) + .ok_or("agent executable path has no parent directory")?; + Ok(directory.join("pending.jsonl")) } fn load_pending_spool(path: &Path) -> Result> { @@ -2537,7 +2634,7 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { let stats = Arc::new(runtime_stats::RuntimeStats::default()); let mut latency_executor = LatencyExecutor::new(Arc::clone(&stats))?; let mut pending_results = Vec::new(); - let spool_path = pending_spool_path(&options.token_file); + let spool_path = pending_spool_path()?; let mut pending_samples = load_pending_spool(&spool_path).unwrap_or_else(|error| { eprintln!("pending report spool ignored: {error}"); Vec::new() @@ -2753,7 +2850,6 @@ fn main() { #[cfg(test)] mod tests { use clap::Parser; - use std::path::PathBuf; use std::time::{Duration, Instant}; use super::{ @@ -2987,34 +3083,27 @@ mod tests { "nodeflare", "-e", "https://monitor.example.com", - "--token-file", - "/run/nodeflare/token", + "-t", + "agent-token", "-i", "60", "--once", ]) .unwrap(); assert_eq!(parsed.endpoint, "https://monitor.example.com"); - assert_eq!(parsed.token_file, PathBuf::from("/run/nodeflare/token")); + assert_eq!(parsed.token, "agent-token"); assert_eq!(parsed.interval, 60); assert!(parsed.once); - assert!(CliOptions::try_parse_from(["nodeflare", "--token-file"]).is_err()); - assert!(CliOptions::try_parse_from([ - "nodeflare", - "--token-file", - "first", - "--token-file", - "second" - ]) - .is_err()); + assert!(CliOptions::try_parse_from(["nodeflare", "-t"]).is_err()); + assert!(CliOptions::try_parse_from(["nodeflare", "-t", "first", "-t", "second"]).is_err()); assert!(CliOptions::try_parse_from([ "nodeflare", "-e", "https://monitor.example.com", + "-t", + "agent-token", "--token-file", "/run/nodeflare/token", - "-s", - "server-a", ]) .is_err()); assert!(CliOptions::try_parse_from(["nodeflare", "--once", "--collect"]).is_err()); @@ -3066,7 +3155,7 @@ mod tests { } #[test] - fn drops_samples_the_worker_would_reject_as_expired() { + fn drops_samples_the_server_would_reject_as_expired() { let mut samples = vec![ Report { timestamp: 1_000, diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..613c1aa --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,2883 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.7", + "sync_wrapper 1.0.2", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http 1.5.0", + "http-body 1.1.0", + "hyper 1.11.1", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nodeflare-backend" +version = "0.0.1" +dependencies = [ + "anyhow", + "argon2", + "axum", + "clap", + "data-encoding", + "futures-util", + "hex", + "pbkdf2", + "rand 0.8.8", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "sqlx", + "time", + "tokio", + "toml", + "totp-lite", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.15", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls 0.23.43", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 1.0.9", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror 2.0.20", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "totp-lite" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e43134db17199f7f721803383ac5854edd0d3d523cc34dba321d6acfbe76c3" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", + "sha1 0.10.7", + "sha2 0.10.9", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.8.8", + "sha1 0.10.7", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..deb9e0a --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "nodeflare-backend" +version = "0.0.1" +edition = "2021" +license = "MIT" +description = "Server monitoring system with WebSocket support" +repository = "https://github.com/imengying/NodeFlare" + +[[bin]] +name = "nodeflare-backend" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["ws", "macros"] } +tokio = { version = "1", features = ["full"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.5", features = ["trace"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "any", "sqlite-bundled", "postgres", "migrate", "macros", "json"] } +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +futures-util = "0.3" +time = { version = "0.3", features = ["serde", "formatting", "parsing"] } +sha2 = "0.11" +pbkdf2 = "0.13" +totp-lite = "2" +clap = { version = "4", features = ["derive"] } +anyhow = "1" +argon2 = "0.5" +rand = "0.8" +hex = "0.4" +reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"] } +data-encoding = "2" +uuid = { version = "1", features = ["v4"] } +url = "2.5" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a1c31b2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,83 @@ +# NodeFlare Backend + +NodeFlare 的独立 Rust/Axum 服务端,支持 SQLite、PostgreSQL、静态前端托管和 WebSocket 实时通信。 + +## 运行 + +```bash +cp config.example.toml config.toml +$EDITOR config.toml +cargo run --locked -- --config config.toml +``` + +从仓库根目录运行时可使用: + +```bash +cargo run --locked --manifest-path backend/Cargo.toml -- \ + --config backend/config.toml +``` + +可通过 `--bind` 和 `--database` 临时覆盖配置中的监听地址或数据库 URL。 + +## 配置 + +配置使用扁平 TOML 字段: + +```toml +database_url = "sqlite://nodeflare.db" +bind_addr = "127.0.0.1:8080" +admin_username = "admin" +admin_password = "replace-with-a-long-random-password" + +turnstile_site_key = "" +turnstile_secret_key = "" + +frontend_dir = "../frontend/dist" +admin_frontend_dir = "../frontend/admin-dist" +agent_dir = "../agent" +session_ttl_hours = 168 +``` + +PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析。 + +## 主要端点 + +公开与访问控制: + +- `GET /api/bootstrap` +- `GET /api/config` +- `GET /api/servers` +- `GET /api/history/:id` +- `GET /api/latency/:id` +- `GET /api/exchange-rates` +- `POST /api/turnstile/verify` +- `POST /api/admin/login` + +管理员接口: + +- `POST /api/admin/logout` +- `GET|PATCH /api/admin/settings` +- `GET|POST /api/admin/2fa/*` +- `GET|POST|PATCH|DELETE /api/admin/servers*` +- `GET|POST|PATCH|DELETE /api/admin/latency-tasks*` +- `GET|POST|PATCH|DELETE /api/admin/alert-rules*` +- `POST /api/admin/remote/task` +- `GET /api/admin/remote/task/:id` +- `GET /api/admin/remote/tasks/:server_id` + +WebSocket: + +- `GET /api/agent/ws`:Agent 上报、配置和远程任务通道 +- `GET /api/ws`:浏览器实时推送通道 + +## 数据库迁移 + +启动时会根据 URL 类型自动运行 `migrations/sqlite` 或 `migrations/postgres`。开发新迁移时必须分别考虑两种数据库的 SQL 语法和类型差异。 + +## 质量检查 + +```bash +cargo fmt -- --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +``` diff --git a/backend/config.example.toml b/backend/config.example.toml new file mode 100644 index 0000000..2e764a2 --- /dev/null +++ b/backend/config.example.toml @@ -0,0 +1,31 @@ +# NodeFlare 配置文件示例 +# 复制为 config.toml 并根据需要修改 + +# 数据库连接 URL +# SQLite: sqlite://path/to/db.sqlite 或 sqlite://nodeflare.db +# PostgreSQL: postgres://user:password@localhost/nodeflare +database_url = "sqlite://nodeflare.db" + +# 服务器监听地址 +bind_addr = "127.0.0.1:8080" + +# 管理员用户名 +admin_username = "admin" + +# 管理员密码(8-128 个字符;启动前必须替换此占位值) +admin_password = "CHANGE_ME_WITH_A_STRONG_PASSWORD" + +# Cloudflare Turnstile 站点密钥(公开) +# 留空则禁用 Turnstile +turnstile_site_key = "" + +# Cloudflare Turnstile 密钥(私密) +turnstile_secret_key = "" + +# 路径相对于本配置文件所在目录 +frontend_dir = "../frontend/dist" +admin_frontend_dir = "../frontend/admin-dist" +agent_dir = "../agent" + +# 管理员会话有效期(小时,范围 1-2160) +session_ttl_hours = 168 diff --git a/backend/config.test.toml b/backend/config.test.toml new file mode 100644 index 0000000..7763811 --- /dev/null +++ b/backend/config.test.toml @@ -0,0 +1,6 @@ +database_url = "sqlite://test_nodeflare.db" +bind_addr = "127.0.0.1:8080" +admin_username = "admin" +admin_password = "admin123" +turnstile_site_key = "" +turnstile_secret_key = "" diff --git a/backend/migrations/postgres/01_initial.sql b/backend/migrations/postgres/01_initial.sql new file mode 100644 index 0000000..b95cc17 --- /dev/null +++ b/backend/migrations/postgres/01_initial.sql @@ -0,0 +1,235 @@ +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + region TEXT NOT NULL DEFAULT '', + group_name TEXT NOT NULL DEFAULT '默认', + tags TEXT NOT NULL DEFAULT '', + hidden BIGINT NOT NULL DEFAULT 0, + sort_order BIGINT NOT NULL DEFAULT 0, + expires_at BIGINT, + traffic_limit BIGINT NOT NULL DEFAULT 0, + traffic_limit_type TEXT NOT NULL DEFAULT 'sum', + price DOUBLE PRECISION NOT NULL DEFAULT 0, + billing_cycle BIGINT NOT NULL DEFAULT 30, + currency TEXT NOT NULL DEFAULT 'CNY', + auto_renewal BIGINT NOT NULL DEFAULT 0, + last_ip TEXT NOT NULL DEFAULT '', + ip_v4 TEXT NOT NULL DEFAULT '', + ip_v6 TEXT NOT NULL DEFAULT '', + network_interface TEXT NOT NULL DEFAULT '', + reset_day BIGINT NOT NULL DEFAULT 1, + report_interval BIGINT NOT NULL DEFAULT 60, + collect_interval BIGINT NOT NULL DEFAULT 1, + rx_correction BIGINT NOT NULL DEFAULT 0, + tx_correction BIGINT NOT NULL DEFAULT 0, + agent_mirror TEXT NOT NULL DEFAULT '', + offline_notify_disabled BIGINT NOT NULL DEFAULT 0, + auto_update BIGINT NOT NULL DEFAULT 1, + token TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX servers_sort ON servers(sort_order, created_at); + +CREATE TABLE metric_history ( + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp BIGINT NOT NULL, + cpu DOUBLE PRECISION NOT NULL DEFAULT 0, + load1 DOUBLE PRECISION NOT NULL DEFAULT 0, + load5 DOUBLE PRECISION NOT NULL DEFAULT 0, + load15 DOUBLE PRECISION NOT NULL DEFAULT 0, + mem_used BIGINT NOT NULL DEFAULT 0, + mem_total BIGINT NOT NULL DEFAULT 0, + swap_used BIGINT NOT NULL DEFAULT 0, + swap_total BIGINT NOT NULL DEFAULT 0, + disk_used BIGINT NOT NULL DEFAULT 0, + disk_total BIGINT NOT NULL DEFAULT 0, + net_in DOUBLE PRECISION NOT NULL DEFAULT 0, + net_out DOUBLE PRECISION NOT NULL DEFAULT 0, + net_rx_total BIGINT NOT NULL DEFAULT 0, + net_tx_total BIGINT NOT NULL DEFAULT 0, + uptime BIGINT NOT NULL DEFAULT 0, + processes BIGINT NOT NULL DEFAULT 0, + tcp_connections BIGINT NOT NULL DEFAULT 0, + udp_connections BIGINT NOT NULL DEFAULT 0, + gpu_usage DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_read_bps DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_write_bps DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_read_iops DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_write_iops DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_await_ms DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_utilization DOUBLE PRECISION NOT NULL DEFAULT 0, + PRIMARY KEY(server_id, timestamp) +); + +CREATE INDEX metric_history_time ON metric_history(timestamp); + +CREATE TABLE server_latest_state ( + server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, + latest_timestamp BIGINT NOT NULL, + cpu DOUBLE PRECISION NOT NULL DEFAULT 0, + mem_used BIGINT NOT NULL DEFAULT 0, + mem_total BIGINT NOT NULL DEFAULT 0, + disk_used BIGINT NOT NULL DEFAULT 0, + disk_total BIGINT NOT NULL DEFAULT 0, + net_in DOUBLE PRECISION NOT NULL DEFAULT 0, + net_out DOUBLE PRECISION NOT NULL DEFAULT 0, + uptime BIGINT NOT NULL DEFAULT 0, + latest_json TEXT NOT NULL DEFAULT '{}', + last_batch_id TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE admin_2fa ( + username TEXT PRIMARY KEY, + totp_secret TEXT NOT NULL, + enabled BIGINT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL +); + +CREATE TABLE remote_tasks ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + command TEXT NOT NULL, + script TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'running', 'success', 'failed')), + requested_by TEXT NOT NULL, + requested_at BIGINT NOT NULL, + started_at BIGINT, + completed_at BIGINT, + result TEXT NOT NULL DEFAULT '', + exit_code BIGINT +); + +CREATE INDEX remote_tasks_server ON remote_tasks(server_id, requested_at DESC); +CREATE INDEX remote_tasks_status ON remote_tasks(status, requested_at DESC); + +CREATE TABLE exchange_rates ( + base_currency TEXT PRIMARY KEY, + rates_json TEXT NOT NULL, + source TEXT NOT NULL, + rate_date TEXT NOT NULL, + fetched_at BIGINT NOT NULL, + attempted_at BIGINT NOT NULL +); + +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL +); + +CREATE INDEX sessions_expires_at ON sessions(expires_at); + +CREATE TABLE dashboard_proofs ( + token_hash TEXT PRIMARY KEY, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL +); + +CREATE INDEX dashboard_proofs_expires_at ON dashboard_proofs(expires_at); + +CREATE TABLE server_traffic_state ( + server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, + cycle_key BIGINT NOT NULL DEFAULT 0, + reset_day BIGINT NOT NULL DEFAULT 1, + timestamp BIGINT NOT NULL DEFAULT 0, + raw_rx BIGINT NOT NULL DEFAULT 0, + raw_tx BIGINT NOT NULL DEFAULT 0, + used_rx BIGINT NOT NULL DEFAULT 0, + used_tx BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE latency_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + task_type TEXT NOT NULL CHECK(task_type IN ('tcp', 'icmp')), + target TEXT NOT NULL, + port BIGINT CHECK(port IS NULL OR port BETWEEN 1 AND 65535), + interval_seconds BIGINT NOT NULL CHECK(interval_seconds BETWEEN 30 AND 3600), + default_enabled BIGINT NOT NULL DEFAULT 0, + sort_order BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + CHECK((task_type = 'tcp' AND port IS NOT NULL) OR (task_type = 'icmp' AND port IS NULL)) +); + +CREATE TABLE latency_task_servers ( + task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + assigned_at BIGINT NOT NULL, + PRIMARY KEY(task_id, server_id) +); + +CREATE INDEX latency_task_servers_server ON latency_task_servers(server_id, task_id); + +CREATE TABLE latency_results ( + task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp BIGINT NOT NULL, + latency_ms DOUBLE PRECISION NOT NULL, + packet_loss DOUBLE PRECISION NOT NULL, + PRIMARY KEY(task_id, server_id, timestamp) +); + +CREATE INDEX latency_results_server_time ON latency_results(server_id, timestamp DESC); + +CREATE TABLE alert_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + metric TEXT NOT NULL CHECK(metric IN ('cpu', 'memory', 'disk', 'net_in', 'net_out')), + threshold DOUBLE PRECISION NOT NULL CHECK(threshold > 0), + duration_minutes BIGINT NOT NULL CHECK(duration_minutes BETWEEN 1 AND 1440), + aggregation TEXT NOT NULL CHECK(aggregation IN ('average', 'continuous')), + enabled BIGINT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE alert_rule_servers ( + rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + PRIMARY KEY(rule_id, server_id) +); + +CREATE TABLE alert_states ( + state_key TEXT PRIMARY KEY, + active BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE TABLE notification_telegram ( + id BIGINT PRIMARY KEY CHECK(id = 1), + bot_token TEXT NOT NULL, + chat_id TEXT NOT NULL, + message_thread_id BIGINT, + template TEXT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE themes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL UNIQUE, + resolved_url TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + created_at BIGINT NOT NULL +); + +CREATE INDEX themes_created_at ON themes(created_at DESC); + +CREATE TABLE theme_previews ( + token_hash TEXT PRIMARY KEY, + theme_id TEXT NOT NULL REFERENCES themes(id) ON DELETE CASCADE, + expires_at BIGINT NOT NULL +); + +CREATE INDEX theme_previews_expires_at ON theme_previews(expires_at); diff --git a/backend/migrations/sqlite/01_initial.sql b/backend/migrations/sqlite/01_initial.sql new file mode 100644 index 0000000..5813ebd --- /dev/null +++ b/backend/migrations/sqlite/01_initial.sql @@ -0,0 +1,116 @@ +-- SQLite migration for NodeFlare backend +PRAGMA foreign_keys = ON; + +-- Settings table (key-value store) +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) WITHOUT ROWID; + +-- Servers table +CREATE TABLE IF NOT EXISTS servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + region TEXT NOT NULL DEFAULT '', + group_name TEXT NOT NULL DEFAULT '默认', + tags TEXT NOT NULL DEFAULT '', + hidden INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER, + traffic_limit INTEGER NOT NULL DEFAULT 0, + traffic_limit_type TEXT NOT NULL DEFAULT 'sum', + price REAL NOT NULL DEFAULT 0, + billing_cycle INTEGER NOT NULL DEFAULT 30, + currency TEXT NOT NULL DEFAULT 'CNY', + auto_renewal INTEGER NOT NULL DEFAULT 0, + last_ip TEXT NOT NULL DEFAULT '', + network_interface TEXT NOT NULL DEFAULT '', + reset_day INTEGER NOT NULL DEFAULT 1, + report_interval INTEGER NOT NULL DEFAULT 60, + collect_interval INTEGER NOT NULL DEFAULT 1, + rx_correction INTEGER NOT NULL DEFAULT 0, + tx_correction INTEGER NOT NULL DEFAULT 0, + agent_mirror TEXT NOT NULL DEFAULT '', + offline_notify_disabled INTEGER NOT NULL DEFAULT 0, + auto_update INTEGER NOT NULL DEFAULT 1, + token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_servers_sort ON servers(sort_order, created_at); + +-- Minute-level metrics history +CREATE TABLE IF NOT EXISTS metric_history ( + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp INTEGER NOT NULL, + cpu REAL NOT NULL DEFAULT 0, + load1 REAL NOT NULL DEFAULT 0, + load5 REAL NOT NULL DEFAULT 0, + load15 REAL NOT NULL DEFAULT 0, + mem_used INTEGER NOT NULL DEFAULT 0, + mem_total INTEGER NOT NULL DEFAULT 0, + swap_used INTEGER NOT NULL DEFAULT 0, + swap_total INTEGER NOT NULL DEFAULT 0, + disk_used INTEGER NOT NULL DEFAULT 0, + disk_total INTEGER NOT NULL DEFAULT 0, + net_in REAL NOT NULL DEFAULT 0, + net_out REAL NOT NULL DEFAULT 0, + net_rx_total INTEGER NOT NULL DEFAULT 0, + net_tx_total INTEGER NOT NULL DEFAULT 0, + uptime INTEGER NOT NULL DEFAULT 0, + processes INTEGER NOT NULL DEFAULT 0, + tcp_connections INTEGER NOT NULL DEFAULT 0, + udp_connections INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(server_id, timestamp) +) WITHOUT ROWID; + +-- Latest state per server +CREATE TABLE IF NOT EXISTS server_latest_state ( + server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, + latest_timestamp INTEGER NOT NULL, + cpu REAL NOT NULL DEFAULT 0, + mem_used INTEGER NOT NULL DEFAULT 0, + mem_total INTEGER NOT NULL DEFAULT 0, + disk_used INTEGER NOT NULL DEFAULT 0, + disk_total INTEGER NOT NULL DEFAULT 0, + net_in REAL NOT NULL DEFAULT 0, + net_out REAL NOT NULL DEFAULT 0, + uptime INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID; + +-- 2FA configuration +CREATE TABLE IF NOT EXISTS admin_2fa ( + username TEXT PRIMARY KEY, + totp_secret TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL +) WITHOUT ROWID; + +-- Remote execution tasks +CREATE TABLE IF NOT EXISTS remote_tasks ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + command TEXT NOT NULL, + script TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'running', 'success', 'failed')), + requested_by TEXT NOT NULL, + requested_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + result TEXT NOT NULL DEFAULT '', + exit_code INTEGER +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_remote_tasks_server ON remote_tasks(server_id, requested_at DESC); +CREATE INDEX IF NOT EXISTS idx_remote_tasks_status ON remote_tasks(status, requested_at DESC); + +-- Exchange rates +CREATE TABLE IF NOT EXISTS exchange_rates ( + base_currency TEXT PRIMARY KEY, + rates_json TEXT NOT NULL CHECK(json_valid(rates_json)), + source TEXT NOT NULL, + rate_date TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + attempted_at INTEGER NOT NULL +) WITHOUT ROWID; diff --git a/backend/migrations/sqlite/02_complete_standalone.sql b/backend/migrations/sqlite/02_complete_standalone.sql new file mode 100644 index 0000000..1b26301 --- /dev/null +++ b/backend/migrations/sqlite/02_complete_standalone.sql @@ -0,0 +1,133 @@ +PRAGMA foreign_keys = ON; + +ALTER TABLE servers ADD COLUMN ip_v4 TEXT NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN ip_v6 TEXT NOT NULL DEFAULT ''; + +ALTER TABLE metric_history ADD COLUMN gpu_usage REAL NOT NULL DEFAULT 0; +ALTER TABLE metric_history ADD COLUMN disk_read_bps REAL NOT NULL DEFAULT 0; +ALTER TABLE metric_history ADD COLUMN disk_write_bps REAL NOT NULL DEFAULT 0; +ALTER TABLE metric_history ADD COLUMN disk_read_iops REAL NOT NULL DEFAULT 0; +ALTER TABLE metric_history ADD COLUMN disk_write_iops REAL NOT NULL DEFAULT 0; +ALTER TABLE metric_history ADD COLUMN disk_await_ms REAL NOT NULL DEFAULT 0; +ALTER TABLE metric_history ADD COLUMN disk_utilization REAL NOT NULL DEFAULT 0; + +ALTER TABLE server_latest_state ADD COLUMN latest_json TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE server_latest_state ADD COLUMN last_batch_id TEXT NOT NULL DEFAULT ''; + +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX sessions_expires_at ON sessions(expires_at); + +CREATE TABLE dashboard_proofs ( + token_hash TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX dashboard_proofs_expires_at ON dashboard_proofs(expires_at); + +CREATE TABLE server_traffic_state ( + server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, + cycle_key INTEGER NOT NULL DEFAULT 0, + reset_day INTEGER NOT NULL DEFAULT 1, + timestamp INTEGER NOT NULL DEFAULT 0, + raw_rx INTEGER NOT NULL DEFAULT 0, + raw_tx INTEGER NOT NULL DEFAULT 0, + used_rx INTEGER NOT NULL DEFAULT 0, + used_tx INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID; + +CREATE TABLE latency_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + task_type TEXT NOT NULL CHECK(task_type IN ('tcp', 'icmp')), + target TEXT NOT NULL, + port INTEGER CHECK(port IS NULL OR port BETWEEN 1 AND 65535), + interval_seconds INTEGER NOT NULL CHECK(interval_seconds BETWEEN 30 AND 3600), + default_enabled INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK((task_type = 'tcp' AND port IS NOT NULL) OR (task_type = 'icmp' AND port IS NULL)) +); + +CREATE TABLE latency_task_servers ( + task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + assigned_at INTEGER NOT NULL, + PRIMARY KEY(task_id, server_id) +) WITHOUT ROWID; + +CREATE INDEX latency_task_servers_server ON latency_task_servers(server_id, task_id); + +CREATE TABLE latency_results ( + task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp INTEGER NOT NULL, + latency_ms REAL NOT NULL, + packet_loss REAL NOT NULL, + PRIMARY KEY(task_id, server_id, timestamp) +) WITHOUT ROWID; + +CREATE INDEX latency_results_server_time ON latency_results(server_id, timestamp DESC); + +CREATE TABLE alert_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + metric TEXT NOT NULL CHECK(metric IN ('cpu', 'memory', 'disk', 'net_in', 'net_out')), + threshold REAL NOT NULL CHECK(threshold > 0), + duration_minutes INTEGER NOT NULL CHECK(duration_minutes BETWEEN 1 AND 1440), + aggregation TEXT NOT NULL CHECK(aggregation IN ('average', 'continuous')), + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE alert_rule_servers ( + rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + PRIMARY KEY(rule_id, server_id) +) WITHOUT ROWID; + +CREATE TABLE alert_states ( + state_key TEXT PRIMARY KEY, + active INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}' +) WITHOUT ROWID; + +CREATE TABLE notification_telegram ( + id INTEGER PRIMARY KEY CHECK(id = 1), + bot_token TEXT NOT NULL, + chat_id TEXT NOT NULL, + message_thread_id INTEGER, + template TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE themes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL UNIQUE, + resolved_url TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); + +CREATE INDEX themes_created_at ON themes(created_at DESC); + +CREATE TABLE theme_previews ( + token_hash TEXT PRIMARY KEY, + theme_id TEXT NOT NULL REFERENCES themes(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX theme_previews_expires_at ON theme_previews(expires_at); + +PRAGMA optimize; diff --git a/backend/src/auth.rs b/backend/src/auth.rs new file mode 100644 index 0000000..5b14083 --- /dev/null +++ b/backend/src/auth.rs @@ -0,0 +1,75 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use pbkdf2::pbkdf2_hmac; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +pub const CLIENT_PASSWORD_ROUNDS: u32 = 600_000; + +pub fn hash_password(password_derived: &str) -> anyhow::Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(password_derived.as_bytes(), &salt) + .map_err(|error| anyhow::anyhow!(error.to_string()))? + .to_string()) +} + +pub fn verify_password(password_derived: &str, hash: &str) -> bool { + PasswordHash::new(hash).ok().is_some_and(|parsed| { + Argon2::default() + .verify_password(password_derived.as_bytes(), &parsed) + .is_ok() + }) +} + +pub fn derive_client_password(password: &str, deployment_salt: &str) -> String { + let mut digest = [0_u8; 32]; + pbkdf2_hmac::( + password.as_bytes(), + format!("nodeflare:{deployment_salt}").as_bytes(), + CLIENT_PASSWORD_ROUNDS, + &mut digest, + ); + hex::encode(digest) +} + +pub fn random_token(bytes: usize) -> String { + let mut value = vec![0_u8; bytes]; + rand::rngs::OsRng.fill_bytes(&mut value); + hex::encode(value) +} + +pub fn token_hash(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +pub fn valid_password_derived(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashes_and_verifies_derived_passwords() { + let derived = derive_client_password("correct horse battery staple", "deployment-salt"); + let hash = hash_password(&derived).unwrap(); + assert!(verify_password(&derived, &hash)); + assert!(!verify_password( + &derive_client_password("wrong", "deployment-salt"), + &hash + )); + } + + #[test] + fn creates_random_tokens() { + let left = random_token(32); + let right = random_token(32); + assert_eq!(left.len(), 64); + assert_ne!(left, right); + assert_eq!(token_hash(&left).len(), 64); + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..08f285f --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,180 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use serde::Deserialize; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +pub const VERSION: &str = match option_env!("NODEFLARE_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + +#[derive(Debug, Parser)] +#[command(version = VERSION, about = "NodeFlare standalone monitoring server")] +pub struct Args { + /// Path to the TOML configuration file. + #[arg(short, long, default_value = "backend/config.toml")] + pub config: PathBuf, + + /// Override the configured bind address. + #[arg(long)] + pub bind: Option, + + /// Override the configured SQLite or PostgreSQL URL. + #[arg(long)] + pub database: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(default = "default_database_url")] + pub database_url: String, + #[serde(default = "default_bind_addr")] + pub bind_addr: String, + pub admin_username: String, + pub admin_password: String, + #[serde(default)] + pub turnstile_site_key: String, + #[serde(default)] + pub turnstile_secret_key: String, + #[serde(default = "default_public_frontend_dir")] + pub frontend_dir: PathBuf, + #[serde(default = "default_admin_frontend_dir")] + pub admin_frontend_dir: PathBuf, + #[serde(default = "default_agent_dir")] + pub agent_dir: PathBuf, + #[serde(default = "default_session_hours")] + pub session_ttl_hours: i64, +} + +fn default_database_url() -> String { + "sqlite://nodeflare.db".to_string() +} + +fn default_bind_addr() -> String { + "0.0.0.0:8080".to_string() +} + +fn default_public_frontend_dir() -> PathBuf { + PathBuf::from("../frontend/dist") +} + +fn default_admin_frontend_dir() -> PathBuf { + PathBuf::from("../frontend/admin-dist") +} + +fn default_agent_dir() -> PathBuf { + PathBuf::from("../agent") +} + +fn default_session_hours() -> i64 { + 7 * 24 +} + +impl Config { + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read configuration {}", path.display()))?; + let mut config: Config = toml::from_str(&content) + .with_context(|| format!("failed to parse configuration {}", path.display()))?; + let base = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + config.frontend_dir = resolve_path(base, &config.frontend_dir); + config.admin_frontend_dir = resolve_path(base, &config.admin_frontend_dir); + config.agent_dir = resolve_path(base, &config.agent_dir); + config.database_url = resolve_database_url(base, &config.database_url)?; + config.session_ttl_hours = config.session_ttl_hours.clamp(1, 24 * 90); + if config.admin_username.trim().is_empty() + || config.admin_username.chars().any(char::is_whitespace) + || config.admin_username.chars().count() > 64 + { + anyhow::bail!("admin_username must be 1-64 characters without whitespace"); + } + if !(8..=128).contains(&config.admin_password.chars().count()) { + anyhow::bail!("admin_password must be 8-128 characters"); + } + if is_example_password(&config.admin_password) { + anyhow::bail!("admin_password still contains the example placeholder; replace it before starting NodeFlare"); + } + Ok(config) + } +} + +fn is_example_password(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "change_me_with_a_strong_password" | "change-me-with-a-strong-password" + ) +} + +fn resolve_path(base: &Path, value: &Path) -> PathBuf { + if value.is_absolute() { + value.to_path_buf() + } else { + base.join(value) + } +} + +fn resolve_database_url(base: &Path, value: &str) -> Result { + let value = value.trim(); + if value.starts_with("postgres://") || value.starts_with("postgresql://") { + return Ok(value.to_string()); + } + if value == "sqlite::memory:" { + return Ok(value.to_string()); + } + if value.starts_with("sqlite:///") { + return Ok(if value.contains('?') { + value.to_string() + } else { + format!("{value}?mode=rwc") + }); + } + let Some(relative) = value.strip_prefix("sqlite://") else { + anyhow::bail!("database_url must use sqlite://, postgres://, or postgresql://"); + }; + let (path, query) = relative.split_once('?').unwrap_or((relative, "")); + if path.is_empty() { + anyhow::bail!("SQLite database path is empty"); + } + let resolved = resolve_path(base, Path::new(path)); + let resolved = resolved + .to_str() + .context("SQLite database path is not valid UTF-8")?; + Ok(if query.is_empty() { + format!("sqlite://{resolved}?mode=rwc") + } else { + format!("sqlite://{resolved}?{query}") + }) +} + +#[cfg(test)] +mod tests { + use super::{is_example_password, resolve_database_url}; + use std::path::Path; + + #[test] + fn resolves_database_relative_to_config() { + assert_eq!( + resolve_database_url(Path::new("/srv/nodeflare/backend"), "sqlite://nodeflare.db") + .unwrap(), + "sqlite:///srv/nodeflare/backend/nodeflare.db?mode=rwc" + ); + } + + #[test] + fn accepts_postgres_urls() { + assert_eq!( + resolve_database_url(Path::new("."), "postgres://localhost/nodeflare").unwrap(), + "postgres://localhost/nodeflare" + ); + } + + #[test] + fn detects_example_password_placeholders() { + assert!(is_example_password("CHANGE_ME_WITH_A_STRONG_PASSWORD")); + assert!(!is_example_password("a-real-password-123")); + } +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs new file mode 100644 index 0000000..85d6447 --- /dev/null +++ b/backend/src/db/mod.rs @@ -0,0 +1,582 @@ +pub mod queries; + +use crate::auth; +use crate::config::Config; +use crate::models::{PublicConfig, SettingsInput, SettingsView}; +use anyhow::Result; +use sqlx::any::{install_default_drivers, AnyPoolOptions}; +use sqlx::migrate::Migrator; +use sqlx::{AnyPool, Row}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +pub const SECRET_MASK: &str = "********"; +const PASSWORD_SCHEME: &str = "argon2-client-pbkdf2-v1"; + +static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("./migrations/sqlite"); +static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("./migrations/postgres"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DatabaseKind { + Sqlite, + Postgres, +} + +#[derive(Clone)] +pub struct Database { + pool: AnyPool, + kind: DatabaseKind, +} + +impl Database { + pub fn pool(&self) -> &AnyPool { + &self.pool + } + + pub fn kind(&self) -> DatabaseKind { + self.kind + } + + pub fn is_postgres(&self) -> bool { + self.kind == DatabaseKind::Postgres + } + + pub fn sql(&self, statement: &'static str) -> &'static str { + if self.kind == DatabaseKind::Sqlite { + return statement; + } + static TRANSLATED: OnceLock>> = OnceLock::new(); + let translated = TRANSLATED.get_or_init(|| Mutex::new(HashMap::new())); + let mut translated = translated.lock().expect("SQL translation lock poisoned"); + if let Some(value) = translated.get(statement) { + return value; + } + let mut index = 0; + let mut output = String::with_capacity(statement.len() + 16); + for character in statement.chars() { + if character == '?' { + index += 1; + output.push('$'); + output.push_str(&index.to_string()); + } else { + output.push(character); + } + } + let output = Box::leak(output.into_boxed_str()); + translated.insert(statement, output); + output + } + + pub async fn migrate(&self) -> Result<()> { + match self.kind { + DatabaseKind::Sqlite => SQLITE_MIGRATOR.run(&self.pool).await?, + DatabaseKind::Postgres => POSTGRES_MIGRATOR.run(&self.pool).await?, + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct Settings { + pub site_name: String, + pub site_description: String, + pub site_announcement: String, + pub logo_url: String, + pub locale: String, + pub public_dashboard: bool, + pub offline_threshold_seconds: i64, + pub history_retention_days: i64, + pub default_theme: String, + pub active_theme_id: String, + pub background_url: String, + pub theme_options: serde_json::Value, + pub show_search: bool, + pub show_groups: bool, + pub show_stats: bool, + pub show_assets: bool, + pub show_traffic: bool, + pub show_speed: bool, + pub show_price: bool, + pub show_expiry: bool, + pub show_latency: bool, + pub show_uptime: bool, + pub admin_username: String, + pub admin_password_hash: String, + pub password_client_salt: String, + pub turnstile_enabled: bool, + pub turnstile_login_enabled: bool, + pub turnstile_site_key: String, + pub turnstile_secret_key: String, + pub notification_enabled: bool, + pub offline_alert_minutes: i64, + pub expiry_alert_days: i64, + pub traffic_alert_percentage: i64, +} + +impl Settings { + pub fn public_config(&self) -> PublicConfig { + let turnstile_configured = !self.turnstile_site_key.trim().is_empty() + && !self.turnstile_secret_key.trim().is_empty(); + PublicConfig { + site_name: self.site_name.clone(), + site_description: self.site_description.clone(), + site_announcement: self.site_announcement.clone(), + logo_url: self.logo_url.clone(), + locale: self.locale.clone(), + public_dashboard: self.public_dashboard, + offline_threshold_seconds: self.offline_threshold_seconds, + history_retention_days: self.history_retention_days, + default_theme: self.default_theme.clone(), + active_theme_id: self.active_theme_id.clone(), + background_url: self.background_url.clone(), + theme_options: self.theme_options.clone(), + show_search: self.show_search, + show_groups: self.show_groups, + show_stats: self.show_stats, + show_assets: self.show_assets, + show_traffic: self.show_traffic, + show_speed: self.show_speed, + show_price: self.show_price, + show_expiry: self.show_expiry, + show_latency: self.show_latency, + show_uptime: self.show_uptime, + turnstile_enabled: self.turnstile_enabled && turnstile_configured, + turnstile_login_enabled: (self.turnstile_login_enabled || self.turnstile_enabled) + && turnstile_configured, + turnstile_site_key: if turnstile_configured { + self.turnstile_site_key.clone() + } else { + String::new() + }, + password_client_salt: self.password_client_salt.clone(), + } + } + + pub fn admin_view(&self) -> SettingsView { + let mut public = self.public_config(); + public.turnstile_enabled = self.turnstile_enabled; + public.turnstile_login_enabled = self.turnstile_login_enabled; + public.turnstile_site_key = mask_secret(&self.turnstile_site_key); + public.password_client_salt.clear(); + SettingsView { + public, + admin_username: self.admin_username.clone(), + admin_password_configured: !self.admin_password_hash.is_empty(), + turnstile_secret_key: mask_secret(&self.turnstile_secret_key), + notification_enabled: self.notification_enabled, + offline_alert_minutes: self.offline_alert_minutes, + expiry_alert_days: self.expiry_alert_days, + traffic_alert_percentage: self.traffic_alert_percentage, + } + } +} + +pub async fn connect(database_url: &str) -> Result { + install_default_drivers(); + let kind = if database_url.starts_with("sqlite:") { + DatabaseKind::Sqlite + } else if database_url.starts_with("postgres:") || database_url.starts_with("postgresql:") { + DatabaseKind::Postgres + } else { + anyhow::bail!("unsupported database URL"); + }; + let pool = AnyPoolOptions::new() + .max_connections(if kind == DatabaseKind::Sqlite { 1 } else { 10 }) + .connect(database_url) + .await?; + if kind == DatabaseKind::Sqlite { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&pool) + .await?; + sqlx::query("PRAGMA journal_mode = WAL") + .execute(&pool) + .await?; + sqlx::query("PRAGMA busy_timeout = 10000") + .execute(&pool) + .await?; + } + Ok(Database { pool, kind }) +} + +pub async fn initialize(pool: &Database, config: &Config) -> Result<()> { + let now = now(); + let salt = get_setting(pool, "password_client_salt") + .await? + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| auth::random_token(16)); + + let defaults = [ + ("site_name", "NodeFlare".to_string()), + ("site_description", "轻量、实时的服务器运行状态".to_string()), + ("site_announcement", String::new()), + ("logo_url", String::new()), + ("locale", "zh-CN".to_string()), + ("public_dashboard", "true".to_string()), + ("offline_threshold_seconds", "180".to_string()), + ("history_retention_days", "30".to_string()), + ("default_theme", "system".to_string()), + ("active_theme_id", "builtin-nodeflare-glass".to_string()), + ("background_url", String::new()), + ("theme_options", "{}".to_string()), + ("show_search", "true".to_string()), + ("show_groups", "true".to_string()), + ("show_stats", "true".to_string()), + ("show_assets", "true".to_string()), + ("show_traffic", "true".to_string()), + ("show_speed", "true".to_string()), + ("show_price", "true".to_string()), + ("show_expiry", "true".to_string()), + ("show_latency", "true".to_string()), + ("show_uptime", "true".to_string()), + ("admin_username", config.admin_username.trim().to_string()), + ("password_client_salt", salt.clone()), + ("turnstile_enabled", "false".to_string()), + ("turnstile_login_enabled", "true".to_string()), + ( + "turnstile_site_key", + config.turnstile_site_key.trim().to_string(), + ), + ( + "turnstile_secret_key", + config.turnstile_secret_key.trim().to_string(), + ), + ("notification_enabled", "false".to_string()), + ("offline_alert_minutes", "5".to_string()), + ("expiry_alert_days", "7".to_string()), + ("traffic_alert_percentage", "80".to_string()), + ("history_cache_version", "0".to_string()), + ]; + + let mut transaction = pool.pool().begin().await?; + for (key, value) in defaults { + sqlx::query( + pool.sql("INSERT INTO settings(key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING"), + ) + .bind(key) + .bind(value) + .execute(&mut *transaction) + .await?; + } + + let scheme = + sqlx::query_scalar::<_, String>(pool.sql("SELECT value FROM settings WHERE key = ?")) + .bind("password_scheme") + .fetch_optional(&mut *transaction) + .await?; + if scheme.as_deref() != Some(PASSWORD_SCHEME) { + let derived = auth::derive_client_password(&config.admin_password, &salt); + let password_hash = auth::hash_password(&derived)?; + for (key, value) in [ + ("admin_username", config.admin_username.trim()), + ("admin_password_hash", password_hash.as_str()), + ("password_client_salt", salt.as_str()), + ("password_scheme", PASSWORD_SCHEME), + ] { + sqlx::query(pool.sql( + "INSERT INTO settings(key, value) VALUES (?, ?) \ + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + )) + .bind(key) + .bind(value) + .execute(&mut *transaction) + .await?; + } + sqlx::query("DELETE FROM sessions") + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM settings WHERE key LIKE 'session_%'") + .execute(&mut *transaction) + .await?; + tracing::info!(timestamp = now, "initialized standalone password scheme"); + } + transaction.commit().await?; + Ok(()) +} + +pub async fn load_settings(pool: &Database) -> Result { + let rows = sqlx::query("SELECT key, value FROM settings") + .fetch_all(pool.pool()) + .await?; + let values = rows + .into_iter() + .map(|row| (row.get::("key"), row.get::("value"))) + .collect::>(); + Ok(Settings { + site_name: string(&values, "site_name", "NodeFlare"), + site_description: string(&values, "site_description", "轻量、实时的服务器运行状态"), + site_announcement: string(&values, "site_announcement", ""), + logo_url: string(&values, "logo_url", ""), + locale: string(&values, "locale", "zh-CN"), + public_dashboard: boolean(&values, "public_dashboard", true), + offline_threshold_seconds: integer(&values, "offline_threshold_seconds", 180) + .clamp(30, 3600), + history_retention_days: integer(&values, "history_retention_days", 30).clamp(1, 3650), + default_theme: string(&values, "default_theme", "system"), + active_theme_id: string(&values, "active_theme_id", "builtin-nodeflare-glass"), + background_url: string(&values, "background_url", ""), + theme_options: values + .get("theme_options") + .and_then(|value| serde_json::from_str(value).ok()) + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})), + show_search: boolean(&values, "show_search", true), + show_groups: boolean(&values, "show_groups", true), + show_stats: boolean(&values, "show_stats", true), + show_assets: boolean(&values, "show_assets", true), + show_traffic: boolean(&values, "show_traffic", true), + show_speed: boolean(&values, "show_speed", true), + show_price: boolean(&values, "show_price", true), + show_expiry: boolean(&values, "show_expiry", true), + show_latency: boolean(&values, "show_latency", true), + show_uptime: boolean(&values, "show_uptime", true), + admin_username: string(&values, "admin_username", "admin"), + admin_password_hash: string(&values, "admin_password_hash", ""), + password_client_salt: string(&values, "password_client_salt", ""), + turnstile_enabled: boolean(&values, "turnstile_enabled", false), + turnstile_login_enabled: boolean(&values, "turnstile_login_enabled", true), + turnstile_site_key: string(&values, "turnstile_site_key", ""), + turnstile_secret_key: string(&values, "turnstile_secret_key", ""), + notification_enabled: boolean(&values, "notification_enabled", false), + offline_alert_minutes: integer(&values, "offline_alert_minutes", 5).clamp(2, 1440), + expiry_alert_days: integer(&values, "expiry_alert_days", 7).clamp(0, 365), + traffic_alert_percentage: integer(&values, "traffic_alert_percentage", 80).clamp(50, 100), + }) +} + +pub async fn update_settings( + pool: &Database, + input: &SettingsInput, + password_hash: Option<&str>, +) -> Result<()> { + let mut updates = Vec::<(&str, String)>::new(); + macro_rules! text { + ($field:ident, $key:literal) => { + if let Some(value) = input.$field.as_deref() { + updates.push(($key, value.trim().to_string())); + } + }; + } + macro_rules! boolean { + ($field:ident, $key:literal) => { + if let Some(value) = input.$field { + updates.push(($key, value.to_string())); + } + }; + } + text!(site_name, "site_name"); + text!(site_description, "site_description"); + text!(site_announcement, "site_announcement"); + text!(logo_url, "logo_url"); + text!(locale, "locale"); + boolean!(public_dashboard, "public_dashboard"); + if let Some(value) = input.offline_threshold_seconds { + updates.push(( + "offline_threshold_seconds", + value.clamp(30, 3600).to_string(), + )); + } + if let Some(value) = input.history_retention_days { + updates.push(("history_retention_days", value.clamp(1, 3650).to_string())); + } + text!(default_theme, "default_theme"); + text!(active_theme_id, "active_theme_id"); + text!(background_url, "background_url"); + if let Some(value) = input.theme_options.as_ref() { + updates.push(("theme_options", value.to_string())); + } + boolean!(show_search, "show_search"); + boolean!(show_groups, "show_groups"); + boolean!(show_stats, "show_stats"); + boolean!(show_assets, "show_assets"); + boolean!(show_traffic, "show_traffic"); + boolean!(show_speed, "show_speed"); + boolean!(show_price, "show_price"); + boolean!(show_expiry, "show_expiry"); + boolean!(show_latency, "show_latency"); + boolean!(show_uptime, "show_uptime"); + text!(admin_username, "admin_username"); + if let Some(value) = password_hash { + updates.push(("admin_password_hash", value.to_string())); + updates.push(("password_scheme", PASSWORD_SCHEME.to_string())); + } + boolean!(turnstile_enabled, "turnstile_enabled"); + boolean!(turnstile_login_enabled, "turnstile_login_enabled"); + if let Some(value) = input + .turnstile_site_key + .as_deref() + .filter(|value| value.trim() != SECRET_MASK) + { + updates.push(("turnstile_site_key", value.trim().to_string())); + } + if let Some(value) = input + .turnstile_secret_key + .as_deref() + .filter(|value| value.trim() != SECRET_MASK) + { + updates.push(("turnstile_secret_key", value.trim().to_string())); + } + boolean!(notification_enabled, "notification_enabled"); + if let Some(value) = input.offline_alert_minutes { + updates.push(("offline_alert_minutes", value.clamp(2, 1440).to_string())); + } + if let Some(value) = input.expiry_alert_days { + updates.push(("expiry_alert_days", value.clamp(0, 365).to_string())); + } + if let Some(value) = input.traffic_alert_percentage { + updates.push(("traffic_alert_percentage", value.clamp(50, 100).to_string())); + } + + let mut transaction = pool.pool().begin().await?; + for (key, value) in updates { + sqlx::query(pool.sql( + "INSERT INTO settings(key, value) VALUES (?, ?) \ + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + )) + .bind(key) + .bind(value) + .execute(&mut *transaction) + .await?; + } + if password_hash.is_some() || input.admin_username.is_some() { + sqlx::query("DELETE FROM sessions") + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(()) +} + +pub async fn get_setting(pool: &Database, key: &str) -> Result> { + Ok( + sqlx::query_scalar::<_, String>(pool.sql("SELECT value FROM settings WHERE key = ?")) + .bind(key) + .fetch_optional(pool.pool()) + .await?, + ) +} + +pub async fn set_setting(pool: &Database, key: &str, value: &str) -> Result<()> { + sqlx::query(pool.sql( + "INSERT INTO settings(key, value) VALUES (?, ?) \ + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + )) + .bind(key) + .bind(value) + .execute(pool.pool()) + .await?; + Ok(()) +} + +pub async fn create_session(pool: &Database, username: &str, ttl_hours: i64) -> Result { + let token = auth::random_token(32); + let created_at = now(); + let expires_at = created_at.saturating_add(ttl_hours.clamp(1, 24 * 90) * 3600); + sqlx::query(pool.sql( + "INSERT INTO sessions(token_hash, username, created_at, expires_at) VALUES (?, ?, ?, ?)", + )) + .bind(auth::token_hash(&token)) + .bind(username) + .bind(created_at) + .bind(expires_at) + .execute(pool.pool()) + .await?; + Ok(token) +} + +pub async fn session_username(pool: &Database, token: &str) -> Result> { + if token.is_empty() || token.len() > 512 { + return Ok(None); + } + let current = now(); + Ok(sqlx::query_scalar::<_, String>( + pool.sql("SELECT username FROM sessions WHERE token_hash = ? AND expires_at > ?"), + ) + .bind(auth::token_hash(token)) + .bind(current) + .fetch_optional(pool.pool()) + .await?) +} + +pub async fn revoke_session(pool: &Database, token: &str) -> Result<()> { + sqlx::query(pool.sql("DELETE FROM sessions WHERE token_hash = ?")) + .bind(auth::token_hash(token)) + .execute(pool.pool()) + .await?; + Ok(()) +} + +pub async fn create_dashboard_proof(pool: &Database) -> Result { + let token = auth::random_token(32); + let created_at = now(); + sqlx::query( + pool.sql( + "INSERT INTO dashboard_proofs(token_hash, created_at, expires_at) VALUES (?, ?, ?)", + ), + ) + .bind(auth::token_hash(&token)) + .bind(created_at) + .bind(created_at + 3600) + .execute(pool.pool()) + .await?; + Ok(token) +} + +pub async fn valid_dashboard_proof(pool: &Database, token: &str) -> Result { + if token.is_empty() || token.len() > 512 { + return Ok(false); + } + let count = sqlx::query_scalar::<_, i64>( + pool.sql("SELECT COUNT(*) FROM dashboard_proofs WHERE token_hash = ? AND expires_at > ?"), + ) + .bind(auth::token_hash(token)) + .bind(now()) + .fetch_one(pool.pool()) + .await?; + Ok(count > 0) +} + +pub async fn cleanup_auth(pool: &Database) -> Result<()> { + let current = now(); + sqlx::query(pool.sql("DELETE FROM sessions WHERE expires_at <= ?")) + .bind(current) + .execute(pool.pool()) + .await?; + sqlx::query(pool.sql("DELETE FROM dashboard_proofs WHERE expires_at <= ?")) + .bind(current) + .execute(pool.pool()) + .await?; + Ok(()) +} + +pub fn now() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp() +} + +fn string(values: &HashMap, key: &str, fallback: &str) -> String { + values + .get(key) + .cloned() + .unwrap_or_else(|| fallback.to_string()) +} + +fn integer(values: &HashMap, key: &str, fallback: i64) -> i64 { + values + .get(key) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn boolean(values: &HashMap, key: &str, fallback: bool) -> bool { + values + .get(key) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn mask_secret(value: &str) -> String { + if value.is_empty() { + String::new() + } else { + SECRET_MASK.to_string() + } +} diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs new file mode 100644 index 0000000..ce115f8 --- /dev/null +++ b/backend/src/db/queries.rs @@ -0,0 +1,1785 @@ +use super::{now, Database, SECRET_MASK}; +use crate::auth; +use crate::models::{ + AgentLatencyResult, AgentLatencyTask, AgentReport, AlertRuleInput, AlertRuleView, + DatabaseStats, HistoryPoint, LatencySample, LatencyTaskInput, LatencyTaskView, RemoteTaskInfo, + ServerInput, ServerView, TelegramSettingsInput, TelegramSettingsView, ThemeInput, ThemeView, +}; +use anyhow::{Context, Result}; +use sqlx::{Any, Row, Transaction}; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone)] +pub struct AgentIdentity { + pub server_id: String, + pub hidden: bool, + pub report_interval: i64, + pub collect_interval: i64, + pub reset_day: i64, + pub rx_correction: i64, + pub tx_correction: i64, +} + +#[derive(Debug, Clone)] +pub struct PersistResult { + pub reports: Vec, + pub persisted_through: i64, +} + +#[derive(Debug, Clone, Default)] +struct TrafficState { + cycle_key: i64, + reset_day: i64, + timestamp: i64, + raw_rx: i64, + raw_tx: i64, + used_rx: i64, + used_tx: i64, +} + +pub async fn list_servers(db: &Database, include_hidden: bool) -> Result> { + let statement = if include_hidden { + "SELECT s.id, s.name, s.region, s.group_name, s.tags, s.hidden, s.expires_at, \ + s.traffic_limit, s.traffic_limit_type, s.price, s.billing_cycle, s.currency, \ + s.auto_renewal, s.last_ip, s.ip_v4, s.ip_v6, s.network_interface, s.reset_day, \ + s.report_interval, s.collect_interval, s.rx_correction, s.tx_correction, \ + s.agent_mirror, s.offline_notify_disabled, s.auto_update, \ + l.latest_timestamp, l.latest_json, l.cpu AS legacy_cpu, l.mem_used AS legacy_mem_used, \ + l.mem_total AS legacy_mem_total, l.disk_used AS legacy_disk_used, \ + l.disk_total AS legacy_disk_total, l.net_in AS legacy_net_in, \ + l.net_out AS legacy_net_out, l.uptime AS legacy_uptime \ + FROM servers s LEFT JOIN server_latest_state l ON l.server_id=s.id \ + ORDER BY s.sort_order, s.created_at" + } else { + "SELECT s.id, s.name, s.region, s.group_name, s.tags, s.hidden, s.expires_at, \ + s.traffic_limit, s.traffic_limit_type, s.price, s.billing_cycle, s.currency, \ + s.auto_renewal, s.last_ip, s.ip_v4, s.ip_v6, s.network_interface, s.reset_day, \ + s.report_interval, s.collect_interval, s.rx_correction, s.tx_correction, \ + s.agent_mirror, s.offline_notify_disabled, s.auto_update, \ + l.latest_timestamp, l.latest_json, l.cpu AS legacy_cpu, l.mem_used AS legacy_mem_used, \ + l.mem_total AS legacy_mem_total, l.disk_used AS legacy_disk_used, \ + l.disk_total AS legacy_disk_total, l.net_in AS legacy_net_in, \ + l.net_out AS legacy_net_out, l.uptime AS legacy_uptime \ + FROM servers s LEFT JOIN server_latest_state l ON l.server_id=s.id \ + WHERE s.hidden=0 ORDER BY s.sort_order, s.created_at" + }; + let rows = sqlx::query(statement).fetch_all(db.pool()).await?; + let latency = latest_latency_map(db).await?; + rows.into_iter() + .map(|row| { + let id: String = row.try_get("id")?; + let timestamp: Option = row.try_get("latest_timestamp")?; + let latest_json: Option = row.try_get("latest_json")?; + let report = latest_json + .as_deref() + .filter(|value| !value.is_empty() && *value != "{}") + .and_then(|value| serde_json::from_str::(value).ok()); + let legacy = timestamp.map(|timestamp| AgentReport { + timestamp, + cpu: row + .try_get::, _>("legacy_cpu") + .ok() + .flatten() + .unwrap_or(0.0), + mem_used: row + .try_get::, _>("legacy_mem_used") + .ok() + .flatten() + .unwrap_or(0), + mem_total: row + .try_get::, _>("legacy_mem_total") + .ok() + .flatten() + .unwrap_or(0), + disk_used: row + .try_get::, _>("legacy_disk_used") + .ok() + .flatten() + .unwrap_or(0), + disk_total: row + .try_get::, _>("legacy_disk_total") + .ok() + .flatten() + .unwrap_or(0), + net_in: row + .try_get::, _>("legacy_net_in") + .ok() + .flatten() + .unwrap_or(0.0), + net_out: row + .try_get::, _>("legacy_net_out") + .ok() + .flatten() + .unwrap_or(0.0), + uptime: row + .try_get::, _>("legacy_uptime") + .ok() + .flatten() + .unwrap_or(0), + ..AgentReport::default() + }); + let report = report.or(legacy); + let value = |read: fn(&AgentReport) -> f64| report.as_ref().map(read); + let integer = |read: fn(&AgentReport) -> i64| report.as_ref().map(read); + let text = |read: fn(&AgentReport) -> &String| report.as_ref().map(|r| read(r).clone()); + Ok(ServerView { + id: id.clone(), + name: row.try_get("name")?, + region: row.try_get("region")?, + group_name: row.try_get("group_name")?, + tags: row.try_get("tags")?, + hidden: row.try_get::("hidden")? != 0, + expires_at: row.try_get("expires_at")?, + traffic_limit: row.try_get("traffic_limit")?, + traffic_limit_type: row.try_get("traffic_limit_type")?, + price: row.try_get("price")?, + billing_cycle: row.try_get("billing_cycle")?, + currency: row.try_get("currency")?, + auto_renewal: row.try_get::("auto_renewal")? != 0, + last_ip: row.try_get("last_ip")?, + ip_v4: row.try_get("ip_v4")?, + ip_v6: row.try_get("ip_v6")?, + network_interface: row.try_get("network_interface")?, + reset_day: row.try_get("reset_day")?, + report_interval: row.try_get("report_interval")?, + collect_interval: row.try_get("collect_interval")?, + rx_correction: row.try_get("rx_correction")?, + tx_correction: row.try_get("tx_correction")?, + agent_mirror: row.try_get("agent_mirror")?, + offline_notify_disabled: row.try_get::("offline_notify_disabled")? != 0, + auto_update: row.try_get::("auto_update")? != 0, + timestamp: report.as_ref().map(|report| report.timestamp).or(timestamp), + cpu: value(|r| r.cpu), + load1: value(|r| r.load1), + load5: value(|r| r.load5), + load15: value(|r| r.load15), + mem_used: integer(|r| r.mem_used), + mem_total: integer(|r| r.mem_total), + swap_used: integer(|r| r.swap_used), + swap_total: integer(|r| r.swap_total), + disk_used: integer(|r| r.disk_used), + disk_total: integer(|r| r.disk_total), + net_in: value(|r| r.net_in), + net_out: value(|r| r.net_out), + net_rx_total: integer(|r| r.net_rx_total), + net_tx_total: integer(|r| r.net_tx_total), + uptime: integer(|r| r.uptime), + processes: integer(|r| r.processes), + tcp_connections: integer(|r| r.tcp_connections), + udp_connections: integer(|r| r.udp_connections), + cpu_cores: integer(|r| r.cpu_cores), + cpu_model: text(|r| &r.cpu_model), + os: text(|r| &r.os), + kernel: text(|r| &r.kernel), + arch: text(|r| &r.arch), + virtualization: text(|r| &r.virtualization), + gpu_usage: value(|r| r.gpu_usage), + gpu_model: text(|r| &r.gpu_model), + agent_version: text(|r| &r.agent_version), + disk_read_bps: value(|r| r.disk_read_bps), + disk_write_bps: value(|r| r.disk_write_bps), + disk_read_iops: value(|r| r.disk_read_iops), + disk_write_iops: value(|r| r.disk_write_iops), + disk_await_ms: value(|r| r.disk_await_ms), + disk_utilization: value(|r| r.disk_utilization), + disks: report.as_ref().map(|r| r.disks.clone()).unwrap_or_default(), + gpus: report.as_ref().map(|r| r.gpus.clone()).unwrap_or_default(), + latency: latency.get(&id).cloned().unwrap_or_default(), + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) +} + +pub async fn all_server_ids(db: &Database) -> Result> { + let rows = sqlx::query("SELECT id FROM servers") + .fetch_all(db.pool()) + .await?; + Ok(rows + .into_iter() + .filter_map(|row| row.try_get::("id").ok()) + .collect()) +} + +pub async fn create_server(db: &Database, input: &ServerInput) -> Result<(String, String)> { + let id = uuid::Uuid::new_v4().to_string(); + let token = auth::random_token(32); + let timestamp = now(); + let mut transaction = db.pool().begin().await?; + sqlx::query(db.sql( + "INSERT INTO servers( \ + id, token, name, region, group_name, tags, hidden, expires_at, traffic_limit, \ + traffic_limit_type, price, billing_cycle, currency, auto_renewal, last_ip, ip_v4, ip_v6, \ + network_interface, reset_day, report_interval, collect_interval, rx_correction, \ + tx_correction, agent_mirror, offline_notify_disabled, auto_update, created_at, updated_at, \ + sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', '', ?, ?, ?, ?, ?, \ + ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM servers))", + )) + .bind(&id) + .bind(&token) + .bind(input.name.trim()) + .bind(input.region.trim()) + .bind(input.group_name.trim()) + .bind(input.tags.trim()) + .bind(i64::from(input.hidden)) + .bind(input.expires_at) + .bind(input.traffic_limit) + .bind(&input.traffic_limit_type) + .bind(input.price) + .bind(input.billing_cycle) + .bind(input.currency.to_ascii_uppercase()) + .bind(i64::from(input.auto_renewal)) + .bind(input.network_interface.trim()) + .bind(input.reset_day) + .bind(input.report_interval) + .bind(input.collect_interval) + .bind(input.rx_correction) + .bind(input.tx_correction) + .bind(input.agent_mirror.trim().trim_end_matches('/')) + .bind(i64::from(input.offline_notify_disabled)) + .bind(i64::from(input.auto_update)) + .bind(timestamp) + .bind(timestamp) + .execute(&mut *transaction) + .await?; + sqlx::query(db.sql( + "INSERT INTO latency_task_servers(task_id, server_id, assigned_at) \ + SELECT id, ?, ? FROM latency_tasks WHERE default_enabled=1 \ + ON CONFLICT(task_id, server_id) DO NOTHING", + )) + .bind(&id) + .bind(timestamp) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok((id, token)) +} + +pub async fn update_server(db: &Database, id: &str, input: &ServerInput) -> Result { + let result = sqlx::query(db.sql( + "UPDATE servers SET name=?, region=?, group_name=?, tags=?, hidden=?, expires_at=?, \ + traffic_limit=?, traffic_limit_type=?, price=?, billing_cycle=?, currency=?, auto_renewal=?, \ + network_interface=?, reset_day=?, report_interval=?, collect_interval=?, rx_correction=?, \ + tx_correction=?, agent_mirror=?, offline_notify_disabled=?, auto_update=?, updated_at=? \ + WHERE id=?", + )) + .bind(input.name.trim()) + .bind(input.region.trim()) + .bind(input.group_name.trim()) + .bind(input.tags.trim()) + .bind(i64::from(input.hidden)) + .bind(input.expires_at) + .bind(input.traffic_limit) + .bind(&input.traffic_limit_type) + .bind(input.price) + .bind(input.billing_cycle) + .bind(input.currency.to_ascii_uppercase()) + .bind(i64::from(input.auto_renewal)) + .bind(input.network_interface.trim()) + .bind(input.reset_day) + .bind(input.report_interval) + .bind(input.collect_interval) + .bind(input.rx_correction) + .bind(input.tx_correction) + .bind(input.agent_mirror.trim().trim_end_matches('/')) + .bind(i64::from(input.offline_notify_disabled)) + .bind(i64::from(input.auto_update)) + .bind(now()) + .bind(id) + .execute(db.pool()) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn delete_server(db: &Database, id: &str) -> Result { + let result = sqlx::query(db.sql("DELETE FROM servers WHERE id=?")) + .bind(id) + .execute(db.pool()) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn delete_servers(db: &Database, ids: &[String]) -> Result<()> { + let mut transaction = db.pool().begin().await?; + for id in ids { + sqlx::query(db.sql("DELETE FROM servers WHERE id=?")) + .bind(id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(()) +} + +pub async fn reorder_servers(db: &Database, ids: &[String]) -> Result<()> { + let mut transaction = db.pool().begin().await?; + for (index, id) in ids.iter().enumerate() { + sqlx::query(db.sql("UPDATE servers SET sort_order=?, updated_at=? WHERE id=?")) + .bind(index as i64) + .bind(now()) + .bind(id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(()) +} + +pub async fn server_token(db: &Database, id: &str) -> Result> { + Ok( + sqlx::query_scalar::<_, String>(db.sql("SELECT token FROM servers WHERE id=?")) + .bind(id) + .fetch_optional(db.pool()) + .await?, + ) +} + +pub async fn agent_identity(db: &Database, token: &str) -> Result> { + let row = sqlx::query(db.sql( + "SELECT id, hidden, report_interval, collect_interval, reset_day, rx_correction, \ + tx_correction FROM servers WHERE token=?", + )) + .bind(token) + .fetch_optional(db.pool()) + .await?; + row.map(|row| { + Ok::(AgentIdentity { + server_id: row.try_get("id")?, + hidden: row.try_get::("hidden")? != 0, + report_interval: row.try_get("report_interval")?, + collect_interval: row.try_get("collect_interval")?, + reset_day: row.try_get("reset_day")?, + rx_correction: row.try_get("rx_correction")?, + tx_correction: row.try_get("tx_correction")?, + }) + }) + .transpose() + .map_err(Into::into) +} + +pub async fn agent_config(db: &Database, id: &str) -> Result> { + let row = sqlx::query(db.sql( + "SELECT report_interval, collect_interval, network_interface, agent_mirror, auto_update \ + FROM servers WHERE id=?", + )) + .bind(id) + .fetch_optional(db.pool()) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let tasks = tasks_for_server(db, id).await?; + Ok(Some(serde_json::json!({ + "report_interval": row.try_get::("report_interval")?, + "collect_interval": row.try_get::("collect_interval")?, + "network_interface": row.try_get::("network_interface")?, + "agent_mirror": row.try_get::("agent_mirror")?, + "auto_update": row.try_get::("auto_update")? != 0, + "latency_tasks": tasks, + }))) +} + +pub async fn save_agent_batch( + db: &Database, + identity: &AgentIdentity, + batch_id: &str, + reports: &[AgentReport], + remote_ip: &str, +) -> Result { + let mut reports = reports.to_vec(); + reports.sort_by_key(|report| report.timestamp); + reports.dedup_by_key(|report| report.timestamp); + let current = now(); + reports.retain(|report| { + report.timestamp > 0 + && report.timestamp >= current - 7 * 86_400 + && report.timestamp <= current + 300 + }); + if reports.is_empty() { + anyhow::bail!("report batch contains no valid samples"); + } + if reports.len() > 720 { + anyhow::bail!("report batch exceeds 720 samples"); + } + + let mut transaction = db.pool().begin().await?; + let latest_row = sqlx::query( + db.sql("SELECT latest_timestamp, last_batch_id FROM server_latest_state WHERE server_id=?"), + ) + .bind(&identity.server_id) + .fetch_optional(&mut *transaction) + .await?; + let persisted_before = latest_row + .as_ref() + .and_then(|row| row.try_get::("latest_timestamp").ok()) + .unwrap_or(0); + if latest_row.as_ref().is_some_and(|row| { + row.try_get::("last_batch_id").ok().as_deref() == Some(batch_id) + }) { + transaction.rollback().await?; + return Ok(PersistResult { + reports: Vec::new(), + persisted_through: persisted_before, + }); + } + reports.retain(|report| report.timestamp > persisted_before); + if reports.is_empty() { + transaction.rollback().await?; + return Ok(PersistResult { + reports: Vec::new(), + persisted_through: persisted_before, + }); + } + + let mut traffic = load_traffic_state(db, &mut transaction, &identity.server_id).await?; + for report in &mut reports { + apply_traffic(report, &mut traffic, identity); + save_history_row(db, &mut transaction, &identity.server_id, report).await?; + save_latency_rows( + db, + &mut transaction, + &identity.server_id, + &report.latency_results, + ) + .await?; + } + let latest = reports.last().context("report batch became empty")?; + let persisted_through = latest.timestamp; + let latest_json = serde_json::to_string(latest)?; + sqlx::query(db.sql( + "INSERT INTO server_latest_state(server_id, latest_timestamp, cpu, mem_used, mem_total, \ + disk_used, disk_total, net_in, net_out, uptime, latest_json, last_batch_id) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(server_id) DO UPDATE SET latest_timestamp=excluded.latest_timestamp, \ + cpu=excluded.cpu, mem_used=excluded.mem_used, mem_total=excluded.mem_total, \ + disk_used=excluded.disk_used, disk_total=excluded.disk_total, net_in=excluded.net_in, \ + net_out=excluded.net_out, uptime=excluded.uptime, latest_json=excluded.latest_json, \ + last_batch_id=excluded.last_batch_id", + )) + .bind(&identity.server_id) + .bind(latest.timestamp) + .bind(latest.cpu) + .bind(latest.mem_used) + .bind(latest.mem_total) + .bind(latest.disk_used) + .bind(latest.disk_total) + .bind(latest.net_in) + .bind(latest.net_out) + .bind(latest.uptime) + .bind(latest_json) + .bind(batch_id) + .execute(&mut *transaction) + .await?; + save_traffic_state(db, &mut transaction, &identity.server_id, &traffic).await?; + sqlx::query(db.sql( + "UPDATE servers SET last_ip=?, ip_v4=CASE WHEN ?='' THEN ip_v4 ELSE ? END, \ + ip_v6=CASE WHEN ?='' THEN ip_v6 ELSE ? END, updated_at=? WHERE id=?", + )) + .bind(remote_ip) + .bind(&latest.ip_v4) + .bind(&latest.ip_v4) + .bind(&latest.ip_v6) + .bind(&latest.ip_v6) + .bind(current) + .bind(&identity.server_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(PersistResult { + reports, + persisted_through, + }) +} + +async fn load_traffic_state( + db: &Database, + transaction: &mut Transaction<'_, Any>, + server_id: &str, +) -> Result { + let row = sqlx::query(db.sql( + "SELECT cycle_key, reset_day, timestamp, raw_rx, raw_tx, used_rx, used_tx \ + FROM server_traffic_state WHERE server_id=?", + )) + .bind(server_id) + .fetch_optional(&mut **transaction) + .await?; + row.map(|row| { + Ok::(TrafficState { + cycle_key: row.try_get("cycle_key")?, + reset_day: row.try_get("reset_day")?, + timestamp: row.try_get("timestamp")?, + raw_rx: row.try_get("raw_rx")?, + raw_tx: row.try_get("raw_tx")?, + used_rx: row.try_get("used_rx")?, + used_tx: row.try_get("used_tx")?, + }) + }) + .transpose() + .map(|state| state.unwrap_or_default()) + .map_err(Into::into) +} + +fn apply_traffic(report: &mut AgentReport, state: &mut TrafficState, identity: &AgentIdentity) { + if report.timestamp <= state.timestamp { + report.net_rx_total = state.used_rx.saturating_add(identity.rx_correction).max(0); + report.net_tx_total = state.used_tx.saturating_add(identity.tx_correction).max(0); + return; + } + let cycle_key = traffic_cycle_key(report.timestamp, identity.reset_day); + if state.timestamp == 0 { + state.used_rx = report.net_rx_total.max(0); + state.used_tx = report.net_tx_total.max(0); + } else if state.cycle_key != cycle_key || state.reset_day != identity.reset_day { + state.used_rx = 0; + state.used_tx = 0; + } else { + let rx_delta = if report.net_rx_total >= state.raw_rx { + report.net_rx_total - state.raw_rx + } else { + report.net_rx_total + }; + let tx_delta = if report.net_tx_total >= state.raw_tx { + report.net_tx_total - state.raw_tx + } else { + report.net_tx_total + }; + state.used_rx = state.used_rx.saturating_add(rx_delta.max(0)); + state.used_tx = state.used_tx.saturating_add(tx_delta.max(0)); + } + state.cycle_key = cycle_key; + state.reset_day = identity.reset_day; + state.timestamp = report.timestamp; + state.raw_rx = report.net_rx_total.max(0); + state.raw_tx = report.net_tx_total.max(0); + report.net_rx_total = state.used_rx.saturating_add(identity.rx_correction).max(0); + report.net_tx_total = state.used_tx.saturating_add(identity.tx_correction).max(0); +} + +async fn save_traffic_state( + db: &Database, + transaction: &mut Transaction<'_, Any>, + server_id: &str, + state: &TrafficState, +) -> Result<()> { + sqlx::query(db.sql( + "INSERT INTO server_traffic_state(server_id, cycle_key, reset_day, timestamp, raw_rx, \ + raw_tx, used_rx, used_tx) VALUES (?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(server_id) DO UPDATE SET cycle_key=excluded.cycle_key, \ + reset_day=excluded.reset_day, timestamp=excluded.timestamp, raw_rx=excluded.raw_rx, \ + raw_tx=excluded.raw_tx, used_rx=excluded.used_rx, used_tx=excluded.used_tx", + )) + .bind(server_id) + .bind(state.cycle_key) + .bind(state.reset_day) + .bind(state.timestamp) + .bind(state.raw_rx) + .bind(state.raw_tx) + .bind(state.used_rx) + .bind(state.used_tx) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn save_history_row( + db: &Database, + transaction: &mut Transaction<'_, Any>, + server_id: &str, + report: &AgentReport, +) -> Result<()> { + sqlx::query(db.sql( + "INSERT INTO metric_history(server_id, timestamp, cpu, load1, load5, load15, mem_used, \ + mem_total, swap_used, swap_total, disk_used, disk_total, net_in, net_out, net_rx_total, \ + net_tx_total, uptime, processes, tcp_connections, udp_connections, gpu_usage, \ + disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops, disk_await_ms, \ + disk_utilization) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ + ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(server_id, timestamp) DO UPDATE SET cpu=excluded.cpu, \ + load1=excluded.load1, load5=excluded.load5, load15=excluded.load15, \ + mem_used=excluded.mem_used, mem_total=excluded.mem_total, swap_used=excluded.swap_used, \ + swap_total=excluded.swap_total, disk_used=excluded.disk_used, disk_total=excluded.disk_total, \ + net_in=excluded.net_in, net_out=excluded.net_out, net_rx_total=excluded.net_rx_total, \ + net_tx_total=excluded.net_tx_total, uptime=excluded.uptime, processes=excluded.processes, \ + tcp_connections=excluded.tcp_connections, udp_connections=excluded.udp_connections, \ + gpu_usage=excluded.gpu_usage, disk_read_bps=excluded.disk_read_bps, \ + disk_write_bps=excluded.disk_write_bps, disk_read_iops=excluded.disk_read_iops, \ + disk_write_iops=excluded.disk_write_iops, disk_await_ms=excluded.disk_await_ms, \ + disk_utilization=excluded.disk_utilization", + )) + .bind(server_id) + .bind(report.timestamp) + .bind(report.cpu) + .bind(report.load1) + .bind(report.load5) + .bind(report.load15) + .bind(report.mem_used) + .bind(report.mem_total) + .bind(report.swap_used) + .bind(report.swap_total) + .bind(report.disk_used) + .bind(report.disk_total) + .bind(report.net_in) + .bind(report.net_out) + .bind(report.net_rx_total) + .bind(report.net_tx_total) + .bind(report.uptime) + .bind(report.processes) + .bind(report.tcp_connections) + .bind(report.udp_connections) + .bind(report.gpu_usage) + .bind(report.disk_read_bps) + .bind(report.disk_write_bps) + .bind(report.disk_read_iops) + .bind(report.disk_write_iops) + .bind(report.disk_await_ms) + .bind(report.disk_utilization) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn save_latency_rows( + db: &Database, + transaction: &mut Transaction<'_, Any>, + server_id: &str, + results: &[AgentLatencyResult], +) -> Result<()> { + for result in results { + if result.task_id.is_empty() + || result.timestamp <= 0 + || !result.latency_ms.is_finite() + || !result.packet_loss.is_finite() + { + continue; + } + sqlx::query(db.sql( + "INSERT INTO latency_results(task_id, server_id, timestamp, latency_ms, packet_loss) \ + SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM latency_task_servers \ + WHERE task_id=? AND server_id=?) ON CONFLICT(task_id, server_id, timestamp) \ + DO UPDATE SET latency_ms=excluded.latency_ms, packet_loss=excluded.packet_loss", + )) + .bind(&result.task_id) + .bind(server_id) + .bind(result.timestamp) + .bind(result.latency_ms) + .bind(result.packet_loss) + .bind(&result.task_id) + .bind(server_id) + .execute(&mut **transaction) + .await?; + } + Ok(()) +} + +pub async fn history(db: &Database, server_id: &str, hours: i64) -> Result> { + let hours = hours.clamp(1, 24 * 365); + let bucket = (hours * 3600 / 720).max(1); + let since = now() - hours * 3600; + let rows = sqlx::query(db.sql( + "SELECT bucket_timestamp, AVG(cpu) AS cpu, AVG(load1) AS load1, \ + AVG(load5) AS load5, AVG(load15) AS load15, \ + AVG(CAST(mem_used AS DOUBLE PRECISION)) AS mem_used, \ + MAX(mem_total) AS mem_total, \ + AVG(CAST(swap_used AS DOUBLE PRECISION)) AS swap_used, \ + MAX(swap_total) AS swap_total, \ + MAX(disk_used) AS disk_used, MAX(disk_total) AS disk_total, MAX(net_in) AS net_in, \ + MAX(net_out) AS net_out, MAX(net_rx_total) AS net_rx_total, \ + MAX(net_tx_total) AS net_tx_total, MAX(processes) AS processes, \ + MAX(tcp_connections) AS tcp_connections, MAX(udp_connections) AS udp_connections, \ + AVG(gpu_usage) AS gpu_usage, MAX(disk_read_bps) AS disk_read_bps, \ + MAX(disk_write_bps) AS disk_write_bps, MAX(disk_read_iops) AS disk_read_iops, \ + MAX(disk_write_iops) AS disk_write_iops, MAX(disk_await_ms) AS disk_await_ms, \ + MAX(disk_utilization) AS disk_utilization FROM ( \ + SELECT (timestamp / ?) * ? AS bucket_timestamp, metric_history.* \ + FROM metric_history WHERE server_id=? AND timestamp>=? \ + ) samples GROUP BY bucket_timestamp ORDER BY bucket_timestamp", + )) + .bind(bucket) + .bind(bucket) + .bind(server_id) + .bind(since) + .fetch_all(db.pool()) + .await?; + rows.into_iter() + .map(|row| { + Ok(HistoryPoint { + timestamp: row.try_get("bucket_timestamp")?, + cpu: row.try_get::("cpu")?, + load1: row.try_get::("load1")?, + load5: row.try_get::("load5")?, + load15: row.try_get::("load15")?, + mem_used: row.try_get::("mem_used")?.round() as i64, + mem_total: row.try_get("mem_total")?, + swap_used: row.try_get::("swap_used")?.round() as i64, + swap_total: row.try_get("swap_total")?, + disk_used: row.try_get("disk_used")?, + disk_total: row.try_get("disk_total")?, + net_in: row.try_get("net_in")?, + net_out: row.try_get("net_out")?, + net_rx_total: row.try_get("net_rx_total")?, + net_tx_total: row.try_get("net_tx_total")?, + processes: row.try_get("processes")?, + tcp_connections: row.try_get("tcp_connections")?, + udp_connections: row.try_get("udp_connections")?, + gpu_usage: row.try_get("gpu_usage")?, + disk_read_bps: row.try_get("disk_read_bps")?, + disk_write_bps: row.try_get("disk_write_bps")?, + disk_read_iops: row.try_get("disk_read_iops")?, + disk_write_iops: row.try_get("disk_write_iops")?, + disk_await_ms: row.try_get("disk_await_ms")?, + disk_utilization: row.try_get("disk_utilization")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) +} + +pub async fn clear_history(db: &Database) -> Result<()> { + let mut transaction = db.pool().begin().await?; + sqlx::query("DELETE FROM metric_history") + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM latency_results") + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) +} + +pub async fn cleanup_history(db: &Database, retention_days: i64) -> Result<()> { + let cutoff = now() - retention_days.clamp(1, 3650) * 86_400; + sqlx::query(db.sql("DELETE FROM metric_history WHERE timestamp Result { + let cutoff = now() - offline_threshold.clamp(30, 3600); + let row = sqlx::query(db.sql( + "SELECT (SELECT COUNT(*) FROM servers) AS server_count, \ + (SELECT COUNT(*) FROM server_latest_state WHERE latest_timestamp>=?) AS online_count, \ + ((SELECT COUNT(*) FROM metric_history) + (SELECT COUNT(*) FROM latency_results)) \ + AS history_rows", + )) + .bind(cutoff) + .fetch_one(db.pool()) + .await?; + Ok(DatabaseStats { + server_count: row.try_get("server_count")?, + online_count: row.try_get("online_count")?, + history_rows: row.try_get("history_rows")?, + }) +} + +pub async fn public_server_exists(db: &Database, server_id: &str) -> Result { + let count = sqlx::query_scalar::<_, i64>( + db.sql("SELECT COUNT(*) FROM servers WHERE id=? AND hidden=0"), + ) + .bind(server_id) + .fetch_one(db.pool()) + .await?; + Ok(count > 0) +} + +fn traffic_cycle_key(timestamp: i64, reset_day: i64) -> i64 { + let Ok(datetime) = time::OffsetDateTime::from_unix_timestamp(timestamp) else { + return 0; + }; + let date = datetime.date(); + let year = i64::from(date.year()); + let month = date.month() as i64; + let day = i64::from(date.day()); + let boundary = reset_day.clamp(1, 31).min(days_in_month(year, month)); + let current_month = year * 12 + month - 1; + if day >= boundary { + current_month + } else { + current_month - 1 + } +} + +fn days_in_month(year: i64, month: i64) -> i64 { + match month { + 2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +async fn latest_latency_map(db: &Database) -> Result>> { + let rows = sqlx::query( + "SELECT a.server_id, t.id AS task_id, t.name, t.task_type, t.target, t.port, \ + lr.timestamp, lr.latency_ms, lr.packet_loss FROM latency_task_servers a \ + JOIN latency_tasks t ON t.id=a.task_id LEFT JOIN latency_results lr \ + ON lr.task_id=a.task_id AND lr.server_id=a.server_id AND lr.timestamp=( \ + SELECT MAX(newer.timestamp) FROM latency_results newer \ + WHERE newer.task_id=a.task_id AND newer.server_id=a.server_id \ + AND newer.timestamp>=a.assigned_at) \ + ORDER BY t.sort_order, t.created_at", + ) + .fetch_all(db.pool()) + .await?; + let mut result = HashMap::>::new(); + for row in rows { + let server_id: String = row.try_get("server_id")?; + result + .entry(server_id.clone()) + .or_default() + .push(LatencySample { + task_id: row.try_get("task_id")?, + server_id, + name: row.try_get("name")?, + task_type: row.try_get("task_type")?, + target: row.try_get("target")?, + port: row.try_get("port")?, + timestamp: row + .try_get::, _>("timestamp")? + .unwrap_or_default(), + latency_ms: row.try_get::, _>("latency_ms")?.unwrap_or(-1.0), + packet_loss: row + .try_get::, _>("packet_loss")? + .unwrap_or(-1.0), + }); + } + Ok(result) +} + +pub async fn list_latency_tasks(db: &Database) -> Result> { + let rows = sqlx::query( + "SELECT id, name, task_type, target, port, interval_seconds, default_enabled \ + FROM latency_tasks ORDER BY sort_order, created_at", + ) + .fetch_all(db.pool()) + .await?; + let assignments = sqlx::query( + "SELECT task_id, server_id FROM latency_task_servers ORDER BY task_id, server_id", + ) + .fetch_all(db.pool()) + .await?; + let mut by_task = HashMap::>::new(); + for row in assignments { + by_task + .entry(row.try_get("task_id")?) + .or_default() + .push(row.try_get("server_id")?); + } + rows.into_iter() + .map(|row| { + let id: String = row.try_get("id")?; + Ok(LatencyTaskView { + server_ids: by_task.remove(&id).unwrap_or_default(), + id, + name: row.try_get("name")?, + task_type: row.try_get("task_type")?, + target: row.try_get("target")?, + port: row.try_get("port")?, + interval_seconds: row.try_get("interval_seconds")?, + default_enabled: row.try_get::("default_enabled")? != 0, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) +} + +pub async fn tasks_for_server(db: &Database, server_id: &str) -> Result> { + let rows = sqlx::query(db.sql( + "SELECT t.id, t.name, t.task_type, t.target, t.port, t.interval_seconds \ + FROM latency_tasks t JOIN latency_task_servers a ON a.task_id=t.id \ + WHERE a.server_id=? ORDER BY t.sort_order, t.created_at", + )) + .bind(server_id) + .fetch_all(db.pool()) + .await?; + rows.into_iter() + .map(|row| { + Ok(AgentLatencyTask { + id: row.try_get("id")?, + name: row.try_get("name")?, + task_type: row.try_get("task_type")?, + target: row.try_get("target")?, + port: row.try_get("port")?, + interval_seconds: row.try_get("interval_seconds")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) +} + +pub async fn create_latency_task(db: &Database, input: &LatencyTaskInput) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let timestamp = now(); + let mut transaction = db.pool().begin().await?; + sqlx::query(db.sql( + "INSERT INTO latency_tasks(id, name, task_type, target, port, interval_seconds, \ + default_enabled, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, \ + (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM latency_tasks), ?, ?)", + )) + .bind(&id) + .bind(input.name.trim()) + .bind(&input.task_type) + .bind(input.target.trim()) + .bind(input.port) + .bind(input.interval_seconds) + .bind(i64::from(input.default_enabled)) + .bind(timestamp) + .bind(timestamp) + .execute(&mut *transaction) + .await?; + replace_task_servers(db, &mut transaction, &id, &input.server_ids, timestamp).await?; + transaction.commit().await?; + Ok(id) +} + +pub async fn update_latency_task( + db: &Database, + id: &str, + input: &LatencyTaskInput, +) -> Result { + let timestamp = now(); + let mut transaction = db.pool().begin().await?; + let result = sqlx::query(db.sql( + "UPDATE latency_tasks SET name=?, task_type=?, target=?, port=?, interval_seconds=?, \ + default_enabled=?, updated_at=? WHERE id=?", + )) + .bind(input.name.trim()) + .bind(&input.task_type) + .bind(input.target.trim()) + .bind(input.port) + .bind(input.interval_seconds) + .bind(i64::from(input.default_enabled)) + .bind(timestamp) + .bind(id) + .execute(&mut *transaction) + .await?; + if result.rows_affected() == 0 { + transaction.rollback().await?; + return Ok(false); + } + replace_task_servers(db, &mut transaction, id, &input.server_ids, timestamp).await?; + transaction.commit().await?; + Ok(true) +} + +async fn replace_task_servers( + db: &Database, + transaction: &mut Transaction<'_, Any>, + task_id: &str, + server_ids: &[String], + timestamp: i64, +) -> Result<()> { + sqlx::query(db.sql("DELETE FROM latency_task_servers WHERE task_id=?")) + .bind(task_id) + .execute(&mut **transaction) + .await?; + for server_id in server_ids { + sqlx::query(db.sql( + "INSERT INTO latency_task_servers(task_id, server_id, assigned_at) VALUES (?, ?, ?)", + )) + .bind(task_id) + .bind(server_id) + .bind(timestamp) + .execute(&mut **transaction) + .await?; + } + Ok(()) +} + +pub async fn delete_latency_task(db: &Database, id: &str) -> Result { + let result = sqlx::query(db.sql("DELETE FROM latency_tasks WHERE id=?")) + .bind(id) + .execute(db.pool()) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn latency_task_server_ids(db: &Database, id: &str) -> Result> { + let rows = sqlx::query(db.sql("SELECT server_id FROM latency_task_servers WHERE task_id=?")) + .bind(id) + .fetch_all(db.pool()) + .await?; + rows.into_iter() + .map(|row| row.try_get("server_id")) + .collect::, _>>() + .map_err(Into::into) +} + +pub async fn latency_history( + db: &Database, + server_id: &str, + hours: i64, +) -> Result<(Vec, Vec)> { + let tasks = tasks_for_server(db, server_id).await?; + if tasks.is_empty() { + return Ok((tasks, Vec::new())); + } + let bucket = latency_history_bucket_seconds(hours, tasks.len() as i64); + let since = now() - hours.clamp(1, 24 * 365) * 3600; + let rows = sqlx::query(db.sql( + "SELECT task_id, server_id, name, task_type, target, port, \ + bucket_timestamp AS timestamp, \ + CASE WHEN SUM(CASE WHEN latency_ms>=0 THEN 1 ELSE 0 END)>0 \ + THEN SUM(CASE WHEN latency_ms>=0 THEN latency_ms ELSE 0 END) / \ + SUM(CASE WHEN latency_ms>=0 THEN CAST(1 AS DOUBLE PRECISION) \ + ELSE CAST(0 AS DOUBLE PRECISION) END) ELSE -1 END AS latency_ms, \ + AVG(packet_loss) AS packet_loss FROM ( \ + SELECT r.task_id, r.server_id, t.name, t.task_type, t.target, t.port, \ + (r.timestamp / ?) * ? AS bucket_timestamp, r.latency_ms, r.packet_loss \ + FROM latency_results r JOIN latency_tasks t ON t.id=r.task_id \ + JOIN latency_task_servers a ON a.task_id=r.task_id AND a.server_id=r.server_id \ + WHERE r.server_id=? AND r.timestamp>=? AND r.timestamp>=a.assigned_at \ + ) samples GROUP BY task_id, server_id, name, task_type, target, port, bucket_timestamp \ + ORDER BY bucket_timestamp, name LIMIT 4000", + )) + .bind(bucket) + .bind(bucket) + .bind(server_id) + .bind(since) + .fetch_all(db.pool()) + .await?; + let points = rows + .into_iter() + .map(|row| { + Ok(LatencySample { + task_id: row.try_get("task_id")?, + server_id: row.try_get("server_id")?, + name: row.try_get("name")?, + task_type: row.try_get("task_type")?, + target: row.try_get("target")?, + port: row.try_get("port")?, + timestamp: row.try_get("timestamp")?, + latency_ms: row.try_get("latency_ms")?, + packet_loss: row.try_get("packet_loss")?, + }) + }) + .collect::, sqlx::Error>>()?; + Ok((tasks, points)) +} + +fn latency_history_bucket_seconds(hours: i64, task_count: i64) -> i64 { + let hours = hours.clamp(1, 24 * 365); + let task_count = task_count.clamp(1, 128); + let base = match hours { + 1 => 60, + 2..=4 => 120, + 5..=24 => 600, + 25..=168 => 3600, + 169..=720 => 14_400, + _ => 86_400, + }; + let points_per_task = (4000 / task_count).max(1); + let bounded = (hours * 3600 + points_per_task - 1) / points_per_task; + base.max(bounded) +} + +pub async fn list_alert_rules(db: &Database) -> Result> { + let rows = sqlx::query( + "SELECT id, name, metric, threshold, duration_minutes, aggregation, enabled \ + FROM alert_rules ORDER BY created_at", + ) + .fetch_all(db.pool()) + .await?; + let assignments = sqlx::query("SELECT rule_id, server_id FROM alert_rule_servers") + .fetch_all(db.pool()) + .await?; + let mut by_rule = HashMap::>::new(); + for row in assignments { + by_rule + .entry(row.try_get("rule_id")?) + .or_default() + .push(row.try_get("server_id")?); + } + rows.into_iter() + .map(|row| { + let id: String = row.try_get("id")?; + Ok(AlertRuleView { + server_ids: by_rule.remove(&id).unwrap_or_default(), + id, + name: row.try_get("name")?, + metric: row.try_get("metric")?, + threshold: row.try_get("threshold")?, + duration_minutes: row.try_get("duration_minutes")?, + aggregation: row.try_get("aggregation")?, + enabled: row.try_get::("enabled")? != 0, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) +} + +pub async fn create_alert_rule(db: &Database, input: &AlertRuleInput) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let timestamp = now(); + let mut transaction = db.pool().begin().await?; + sqlx::query(db.sql( + "INSERT INTO alert_rules(id, name, metric, threshold, duration_minutes, aggregation, \ + enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + )) + .bind(&id) + .bind(input.name.trim()) + .bind(&input.metric) + .bind(input.threshold) + .bind(input.duration_minutes) + .bind(&input.aggregation) + .bind(i64::from(input.enabled)) + .bind(timestamp) + .bind(timestamp) + .execute(&mut *transaction) + .await?; + replace_alert_servers(db, &mut transaction, &id, &input.server_ids).await?; + transaction.commit().await?; + Ok(id) +} + +pub async fn update_alert_rule(db: &Database, id: &str, input: &AlertRuleInput) -> Result { + let mut transaction = db.pool().begin().await?; + let result = sqlx::query(db.sql( + "UPDATE alert_rules SET name=?, metric=?, threshold=?, duration_minutes=?, aggregation=?, \ + enabled=?, updated_at=? WHERE id=?", + )) + .bind(input.name.trim()) + .bind(&input.metric) + .bind(input.threshold) + .bind(input.duration_minutes) + .bind(&input.aggregation) + .bind(i64::from(input.enabled)) + .bind(now()) + .bind(id) + .execute(&mut *transaction) + .await?; + if result.rows_affected() == 0 { + transaction.rollback().await?; + return Ok(false); + } + replace_alert_servers(db, &mut transaction, id, &input.server_ids).await?; + transaction.commit().await?; + Ok(true) +} + +async fn replace_alert_servers( + db: &Database, + transaction: &mut Transaction<'_, Any>, + rule_id: &str, + server_ids: &[String], +) -> Result<()> { + sqlx::query(db.sql("DELETE FROM alert_rule_servers WHERE rule_id=?")) + .bind(rule_id) + .execute(&mut **transaction) + .await?; + for server_id in server_ids { + sqlx::query(db.sql("INSERT INTO alert_rule_servers(rule_id, server_id) VALUES (?, ?)")) + .bind(rule_id) + .bind(server_id) + .execute(&mut **transaction) + .await?; + } + Ok(()) +} + +pub async fn delete_alert_rule(db: &Database, id: &str) -> Result { + let result = sqlx::query(db.sql("DELETE FROM alert_rules WHERE id=?")) + .bind(id) + .execute(db.pool()) + .await?; + sqlx::query(db.sql("DELETE FROM alert_states WHERE state_key LIKE ?")) + .bind(format!("resource:{id}:%")) + .execute(db.pool()) + .await?; + Ok(result.rows_affected() > 0) +} + +#[derive(Debug, Clone)] +pub struct ResourceAlertEvaluation { + pub rule: AlertRuleView, + pub value: f64, + pub triggered: bool, +} + +pub async fn evaluate_resource_rules( + db: &Database, + server_id: &str, +) -> Result> { + let rules = list_alert_rules(db).await?; + let mut evaluations = Vec::new(); + for rule in rules.into_iter().filter(|rule| { + rule.enabled + && (rule.server_ids.is_empty() || rule.server_ids.iter().any(|id| id == server_id)) + }) { + let expression = match rule.metric.as_str() { + "cpu" => "cpu", + "memory" => { + "CASE WHEN mem_total>0 THEN CAST(mem_used AS DOUBLE PRECISION) * \ + CAST(100 AS DOUBLE PRECISION) / CAST(mem_total AS DOUBLE PRECISION) \ + ELSE CAST(0 AS DOUBLE PRECISION) END" + } + "disk" => { + "CASE WHEN disk_total>0 THEN CAST(disk_used AS DOUBLE PRECISION) * \ + CAST(100 AS DOUBLE PRECISION) / CAST(disk_total AS DOUBLE PRECISION) \ + ELSE CAST(0 AS DOUBLE PRECISION) END" + } + "net_in" => "net_in / CAST(1048576 AS DOUBLE PRECISION)", + "net_out" => "net_out / CAST(1048576 AS DOUBLE PRECISION)", + _ => continue, + }; + let query = if db.is_postgres() { + format!( + "SELECT MIN(timestamp) AS first_timestamp, AVG({expression}) AS average_value, \ + MIN({expression}) AS minimum_value, COUNT(*) AS sample_count \ + FROM metric_history WHERE server_id=$1 AND timestamp>=$2" + ) + } else { + format!( + "SELECT MIN(timestamp) AS first_timestamp, AVG({expression}) AS average_value, \ + MIN({expression}) AS minimum_value, COUNT(*) AS sample_count \ + FROM metric_history WHERE server_id=? AND timestamp>=?" + ) + }; + let since = now() - rule.duration_minutes * 60; + // `expression` and the placeholder syntax above are selected only from + // fixed literals; no request data is interpolated into this SQL. + let row = sqlx::query(sqlx::AssertSqlSafe(query.as_str())) + .bind(server_id) + .bind(since) + .fetch_one(db.pool()) + .await?; + let first: Option = row.try_get("first_timestamp")?; + let count: i64 = row.try_get("sample_count")?; + let average: Option = row.try_get("average_value")?; + let minimum: Option = row.try_get("minimum_value")?; + let value = if rule.aggregation == "continuous" { + minimum.unwrap_or_default() + } else { + average.unwrap_or_default() + }; + let covered = count > 0 && first.is_some_and(|first| first <= since + 5); + evaluations.push(ResourceAlertEvaluation { + triggered: covered && value >= rule.threshold, + rule, + value, + }); + } + Ok(evaluations) +} + +pub async fn update_alert_state( + db: &Database, + key: &str, + active: bool, + details: &serde_json::Value, +) -> Result { + let previous = + sqlx::query_scalar::<_, i64>(db.sql("SELECT active FROM alert_states WHERE state_key=?")) + .bind(key) + .fetch_optional(db.pool()) + .await? + .unwrap_or(0) + != 0; + sqlx::query(db.sql( + "INSERT INTO alert_states(state_key, active, updated_at, details_json) VALUES (?, ?, ?, ?) \ + ON CONFLICT(state_key) DO UPDATE SET active=excluded.active, updated_at=excluded.updated_at, \ + details_json=excluded.details_json", + )) + .bind(key) + .bind(i64::from(active)) + .bind(now()) + .bind(details.to_string()) + .execute(db.pool()) + .await?; + Ok(previous != active) +} + +pub async fn get_totp_secret(db: &Database, username: &str) -> Result> { + let row = sqlx::query(db.sql("SELECT totp_secret, enabled FROM admin_2fa WHERE username=?")) + .bind(username) + .fetch_optional(db.pool()) + .await?; + row.map(|row| { + Ok::<(String, bool), sqlx::Error>(( + row.try_get("totp_secret")?, + row.try_get::("enabled")? != 0, + )) + }) + .transpose() + .map_err(Into::into) +} + +pub async fn save_totp_secret( + db: &Database, + username: &str, + secret: &str, + enabled: bool, +) -> Result<()> { + sqlx::query(db.sql( + "INSERT INTO admin_2fa(username, totp_secret, enabled, created_at) VALUES (?, ?, ?, ?) \ + ON CONFLICT(username) DO UPDATE SET totp_secret=excluded.totp_secret, \ + enabled=excluded.enabled", + )) + .bind(username) + .bind(secret) + .bind(i64::from(enabled)) + .bind(now()) + .execute(db.pool()) + .await?; + Ok(()) +} + +pub async fn set_totp_enabled(db: &Database, username: &str, enabled: bool) -> Result { + let result = sqlx::query(db.sql("UPDATE admin_2fa SET enabled=? WHERE username=?")) + .bind(i64::from(enabled)) + .bind(username) + .execute(db.pool()) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn rename_totp_user(db: &Database, old: &str, new: &str) -> Result<()> { + if old == new { + return Ok(()); + } + sqlx::query(db.sql("UPDATE admin_2fa SET username=? WHERE username=?")) + .bind(new) + .bind(old) + .execute(db.pool()) + .await?; + Ok(()) +} + +pub async fn create_remote_task( + db: &Database, + server_id: &str, + command: &str, + script: &str, + username: &str, +) -> Result { + let task = RemoteTaskInfo { + id: uuid::Uuid::new_v4().to_string(), + server_id: server_id.to_string(), + command: command.to_string(), + script: script.to_string(), + status: "pending".to_string(), + requested_by: username.to_string(), + requested_at: now(), + started_at: None, + completed_at: None, + result: String::new(), + exit_code: None, + }; + sqlx::query(db.sql( + "INSERT INTO remote_tasks(id, server_id, command, script, status, requested_by, \ + requested_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)", + )) + .bind(&task.id) + .bind(&task.server_id) + .bind(&task.command) + .bind(&task.script) + .bind(&task.requested_by) + .bind(task.requested_at) + .execute(db.pool()) + .await?; + Ok(task) +} + +pub async fn remote_task(db: &Database, id: &str) -> Result> { + let row = sqlx::query(db.sql( + "SELECT id, server_id, command, script, status, requested_by, requested_at, started_at, \ + completed_at, result, exit_code FROM remote_tasks WHERE id=?", + )) + .bind(id) + .fetch_optional(db.pool()) + .await?; + row.map(remote_task_from_row) + .transpose() + .map_err(Into::into) +} + +pub async fn server_remote_tasks( + db: &Database, + server_id: &str, + limit: i64, +) -> Result> { + let rows = sqlx::query(db.sql( + "SELECT id, server_id, command, script, status, requested_by, requested_at, started_at, \ + completed_at, result, exit_code FROM remote_tasks WHERE server_id=? \ + ORDER BY requested_at DESC LIMIT ?", + )) + .bind(server_id) + .bind(limit.clamp(1, 200)) + .fetch_all(db.pool()) + .await?; + rows.into_iter() + .map(remote_task_from_row) + .collect::, _>>() + .map_err(Into::into) +} + +pub async fn pending_remote_tasks(db: &Database, server_id: &str) -> Result> { + let rows = sqlx::query(db.sql( + "SELECT id, server_id, command, script, status, requested_by, requested_at, started_at, \ + completed_at, result, exit_code FROM remote_tasks WHERE server_id=? AND status='pending' \ + ORDER BY requested_at LIMIT 50", + )) + .bind(server_id) + .fetch_all(db.pool()) + .await?; + rows.into_iter() + .map(remote_task_from_row) + .collect::, _>>() + .map_err(Into::into) +} + +fn remote_task_from_row( + row: sqlx::any::AnyRow, +) -> std::result::Result { + Ok(RemoteTaskInfo { + id: row.try_get("id")?, + server_id: row.try_get("server_id")?, + command: row.try_get("command")?, + script: row.try_get("script")?, + status: row.try_get("status")?, + requested_by: row.try_get("requested_by")?, + requested_at: row.try_get("requested_at")?, + started_at: row.try_get("started_at")?, + completed_at: row.try_get("completed_at")?, + result: row.try_get("result")?, + exit_code: row.try_get("exit_code")?, + }) +} + +pub async fn mark_remote_task_sent(db: &Database, id: &str, server_id: &str) -> Result<()> { + sqlx::query(db.sql( + "UPDATE remote_tasks SET status='sent', started_at=? \ + WHERE id=? AND server_id=? AND status='pending'", + )) + .bind(now()) + .bind(id) + .bind(server_id) + .execute(db.pool()) + .await?; + Ok(()) +} + +pub async fn update_remote_task_result( + db: &Database, + server_id: &str, + id: &str, + status: &str, + result: &str, + exit_code: Option, +) -> Result { + let result = sqlx::query(db.sql( + "UPDATE remote_tasks SET status=?, result=?, exit_code=?, completed_at=? \ + WHERE id=? AND server_id=?", + )) + .bind(status) + .bind(result) + .bind(exit_code) + .bind(now()) + .bind(id) + .bind(server_id) + .execute(db.pool()) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn telegram_settings(db: &Database) -> Result> { + Ok(raw_telegram_settings(db).await?.map(|mut settings| { + if !settings.bot_token.is_empty() { + settings.bot_token = SECRET_MASK.to_string(); + } + settings + })) +} + +pub async fn raw_telegram_settings(db: &Database) -> Result> { + let row = sqlx::query( + "SELECT bot_token, chat_id, message_thread_id, template FROM notification_telegram \ + WHERE id=1", + ) + .fetch_optional(db.pool()) + .await?; + row.map(|row| { + Ok::(TelegramSettingsView { + bot_token: row.try_get("bot_token")?, + chat_id: row.try_get("chat_id")?, + message_thread_id: row.try_get("message_thread_id")?, + template: row.try_get("template")?, + }) + }) + .transpose() + .map_err(Into::into) +} + +pub async fn save_telegram_settings(db: &Database, input: &TelegramSettingsInput) -> Result<()> { + let current = raw_telegram_settings(db).await?; + let token = if input.bot_token.trim() == SECRET_MASK { + current.map(|value| value.bot_token).unwrap_or_default() + } else { + input.bot_token.trim().to_string() + }; + sqlx::query(db.sql( + "INSERT INTO notification_telegram(id, bot_token, chat_id, message_thread_id, template, \ + updated_at) VALUES (1, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET \ + bot_token=excluded.bot_token, chat_id=excluded.chat_id, \ + message_thread_id=excluded.message_thread_id, template=excluded.template, \ + updated_at=excluded.updated_at", + )) + .bind(token) + .bind(input.chat_id.trim()) + .bind(input.message_thread_id) + .bind(input.template.trim()) + .bind(now()) + .execute(db.pool()) + .await?; + Ok(()) +} + +pub async fn list_themes(db: &Database, active_id: &str) -> Result> { + let rows = sqlx::query( + "SELECT id, name, description, url, version FROM themes ORDER BY created_at DESC", + ) + .fetch_all(db.pool()) + .await?; + let mut themes = vec![ThemeView { + id: "builtin-nodeflare-glass".to_string(), + name: "NodeFlare Glass".to_string(), + description: "默认主题".to_string(), + url: String::new(), + version: crate::config::VERSION.to_string(), + builtin: true, + active: active_id == "builtin-nodeflare-glass", + }]; + for row in rows { + let id: String = row.try_get("id")?; + themes.push(ThemeView { + active: id == active_id, + id, + name: row.try_get("name")?, + description: row.try_get("description")?, + url: row.try_get("url")?, + version: row.try_get("version")?, + builtin: false, + }); + } + Ok(themes) +} + +pub async fn create_theme( + db: &Database, + id: &str, + input: &ThemeInput, + resolved_url: &str, + version: &str, +) -> Result<()> { + sqlx::query(db.sql( + "INSERT INTO themes(id, name, description, url, resolved_url, version, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + )) + .bind(id) + .bind(input.name.trim()) + .bind(input.description.trim()) + .bind(input.url.trim()) + .bind(resolved_url) + .bind(version) + .bind(now()) + .execute(db.pool()) + .await?; + Ok(()) +} + +pub async fn theme_exists(db: &Database, id: &str) -> Result { + let count = sqlx::query_scalar::<_, i64>(db.sql("SELECT COUNT(*) FROM themes WHERE id=?")) + .bind(id) + .fetch_one(db.pool()) + .await?; + Ok(count > 0) +} + +pub async fn theme_resolved_url(db: &Database, id: &str) -> Result> { + Ok( + sqlx::query_scalar::<_, String>(db.sql("SELECT resolved_url FROM themes WHERE id=?")) + .bind(id) + .fetch_optional(db.pool()) + .await?, + ) +} + +pub async fn set_active_theme(db: &Database, id: &str) -> Result { + if id != "builtin-nodeflare-glass" && !theme_exists(db, id).await? { + return Ok(false); + } + super::set_setting(db, "active_theme_id", id).await?; + Ok(true) +} + +pub async fn delete_theme(db: &Database, id: &str) -> Result { + let result = sqlx::query(db.sql("DELETE FROM themes WHERE id=?")) + .bind(id) + .execute(db.pool()) + .await?; + if result.rows_affected() > 0 + && super::get_setting(db, "active_theme_id").await?.as_deref() == Some(id) + { + super::set_setting(db, "active_theme_id", "builtin-nodeflare-glass").await?; + } + Ok(result.rows_affected() > 0) +} + +pub async fn create_theme_preview(db: &Database, theme_id: &str) -> Result { + let token = auth::random_token(24); + sqlx::query( + db.sql("INSERT INTO theme_previews(token_hash, theme_id, expires_at) VALUES (?, ?, ?)"), + ) + .bind(auth::token_hash(&token)) + .bind(theme_id) + .bind(now() + 600) + .execute(db.pool()) + .await?; + Ok(token) +} + +pub async fn theme_preview_url(db: &Database, token: &str) -> Result> { + Ok(sqlx::query_scalar::<_, String>(db.sql( + "SELECT t.resolved_url FROM theme_previews p JOIN themes t ON t.id=p.theme_id \ + WHERE p.token_hash=? AND p.expires_at>?", + )) + .bind(auth::token_hash(token)) + .bind(now()) + .fetch_optional(db.pool()) + .await?) +} + +pub async fn cleanup_theme_previews(db: &Database) -> Result<()> { + sqlx::query(db.sql("DELETE FROM theme_previews WHERE expires_at<=?")) + .bind(now()) + .execute(db.pool()) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct ExchangeSnapshot { + pub base_currency: String, + pub rates_json: String, + pub source: String, + pub rate_date: String, + pub fetched_at: i64, + pub attempted_at: i64, +} + +pub async fn exchange_snapshot(db: &Database, base: &str) -> Result> { + let row = sqlx::query(db.sql( + "SELECT base_currency, rates_json, source, rate_date, fetched_at, attempted_at \ + FROM exchange_rates WHERE base_currency=?", + )) + .bind(base) + .fetch_optional(db.pool()) + .await?; + row.map(|row| { + Ok::(ExchangeSnapshot { + base_currency: row.try_get("base_currency")?, + rates_json: row.try_get("rates_json")?, + source: row.try_get("source")?, + rate_date: row.try_get("rate_date")?, + fetched_at: row.try_get("fetched_at")?, + attempted_at: row.try_get("attempted_at")?, + }) + }) + .transpose() + .map_err(Into::into) +} + +pub async fn mark_exchange_attempt(db: &Database, base: &str, timestamp: i64) -> Result<()> { + sqlx::query(db.sql( + "INSERT INTO exchange_rates(base_currency, rates_json, source, rate_date, fetched_at, \ + attempted_at) VALUES (?, '{}', 'default', '', 0, ?) ON CONFLICT(base_currency) \ + DO UPDATE SET attempted_at=excluded.attempted_at", + )) + .bind(base) + .bind(timestamp) + .execute(db.pool()) + .await?; + Ok(()) +} + +pub async fn upsert_exchange_snapshot( + db: &Database, + base: &str, + rates_json: &str, + source: &str, + date: &str, + timestamp: i64, +) -> Result<()> { + sqlx::query(db.sql( + "INSERT INTO exchange_rates(base_currency, rates_json, source, rate_date, fetched_at, \ + attempted_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(base_currency) DO UPDATE SET \ + rates_json=excluded.rates_json, source=excluded.source, rate_date=excluded.rate_date, \ + fetched_at=excluded.fetched_at, attempted_at=excluded.attempted_at", + )) + .bind(base) + .bind(rates_json) + .bind(source) + .bind(date) + .bind(timestamp) + .bind(timestamp) + .execute(db.pool()) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server_input(traffic_limit: i64) -> ServerInput { + ServerInput { + name: "Large traffic node".to_string(), + region: "CN".to_string(), + group_name: "Test".to_string(), + tags: String::new(), + hidden: false, + expires_at: None, + traffic_limit, + traffic_limit_type: "sum".to_string(), + price: 1.0, + billing_cycle: 30, + currency: "CNY".to_string(), + auto_renewal: false, + network_interface: String::new(), + reset_day: 1, + report_interval: 60, + collect_interval: 5, + rx_correction: 0, + tx_correction: 0, + agent_mirror: String::new(), + offline_notify_disabled: false, + auto_update: true, + } + } + + #[tokio::test] + async fn sqlite_preserves_large_traffic_limits() { + let db = super::super::connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + let expected = 100_i64 * 1024 * 1024 * 1024; + let (id, _) = create_server(&db, &server_input(expected)).await.unwrap(); + let raw = + sqlx::query_scalar::<_, i64>(db.sql("SELECT traffic_limit FROM servers WHERE id=?")) + .bind(&id) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(raw, expected); + let server = list_servers(&db, true) + .await + .unwrap() + .into_iter() + .find(|server| server.id == id) + .unwrap(); + assert_eq!(server.traffic_limit, expected); + } +} diff --git a/backend/src/exchange.rs b/backend/src/exchange.rs new file mode 100644 index 0000000..c81c3b2 --- /dev/null +++ b/backend/src/exchange.rs @@ -0,0 +1,232 @@ +use crate::db::{self, queries, Database}; +use crate::models::ExchangeRatesView; +use anyhow::{Context, Result}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::Duration; + +const BASE: &str = "CNY"; +const PRIMARY_URL: &str = "https://open.er-api.com/v6/latest/CNY"; +const FALLBACK_URL: &str = "https://api.frankfurter.dev/v1/latest?base=CNY"; +const MAX_BODY: usize = 256 * 1024; +const REFRESH_INTERVAL: i64 = 86_400; +const RETRY_INTERVAL: i64 = 3_600; + +struct FetchedRates { + rates: BTreeMap, + source: &'static str, + date: String, +} + +pub async fn current(db: &Database) -> Result { + let snapshot = queries::exchange_snapshot(db, BASE).await?; + Ok(view(snapshot, db::now())) +} + +pub async fn refresh( + db: &Database, + client: &reqwest::Client, + force: bool, +) -> Result { + let current = db::now(); + let snapshot = queries::exchange_snapshot(db, BASE).await?; + let due = force + || snapshot.as_ref().is_none_or(|snapshot| { + (snapshot.fetched_at <= 0 + || current.saturating_sub(snapshot.fetched_at) >= REFRESH_INTERVAL) + && (snapshot.attempted_at <= 0 + || current.saturating_sub(snapshot.attempted_at) >= RETRY_INTERVAL) + }); + if !due { + return Ok(view(snapshot, current)); + } + queries::mark_exchange_attempt(db, BASE, current).await?; + let fetched = match fetch_json(client, PRIMARY_URL).await { + Ok(value) => parse_er_api(&value), + Err(error) => { + tracing::warn!(%error, "primary exchange-rate request failed"); + None + } + }; + let fetched = match fetched { + Some(value) => value, + None => parse_frankfurter(&fetch_json(client, FALLBACK_URL).await?) + .context("exchange-rate fallback returned invalid data")?, + }; + let mut rates = default_rates(); + rates.extend(fetched.rates); + queries::upsert_exchange_snapshot( + db, + BASE, + &serde_json::to_string(&rates)?, + fetched.source, + &fetched.date, + current, + ) + .await?; + current_view(db).await +} + +async fn current_view(db: &Database) -> Result { + Ok(view(queries::exchange_snapshot(db, BASE).await?, db::now())) +} + +async fn fetch_json(client: &reqwest::Client, url: &str) -> Result { + let response = client + .get(url) + .header("Accept", "application/json") + .header("User-Agent", "NodeFlare-Standalone") + .timeout(Duration::from_secs(8)) + .send() + .await? + .error_for_status()?; + if response + .content_length() + .is_some_and(|length| length > MAX_BODY as u64) + { + anyhow::bail!("exchange-rate response is too large"); + } + let body = response.bytes().await?; + if body.len() > MAX_BODY { + anyhow::bail!("exchange-rate response is too large"); + } + Ok(serde_json::from_slice(&body)?) +} + +fn view(snapshot: Option, current: i64) -> ExchangeRatesView { + let snapshot = snapshot.unwrap_or_else(|| queries::ExchangeSnapshot { + base_currency: BASE.to_string(), + rates_json: serde_json::to_string(&default_rates()).unwrap_or_else(|_| "{}".to_string()), + source: "default".to_string(), + rate_date: String::new(), + fetched_at: 0, + attempted_at: 0, + }); + let mut rates = serde_json::from_str::(&snapshot.rates_json) + .ok() + .and_then(|value| sanitize_rates(&value)) + .unwrap_or_else(default_rates); + for (currency, rate) in default_rates() { + rates.entry(currency).or_insert(rate); + } + ExchangeRatesView { + base: snapshot.base_currency, + rates, + source: snapshot.source, + date: snapshot.rate_date, + fetched_at: snapshot.fetched_at, + stale: snapshot.fetched_at <= 0 + || current.saturating_sub(snapshot.fetched_at) >= REFRESH_INTERVAL, + } +} + +fn default_rates() -> BTreeMap { + [ + ("CNY", 1.0), + ("USD", 0.14799), + ("CAD", 0.2086), + ("HKD", 1.1594), + ("EUR", 0.1275), + ("GBP", 0.11027), + ("JPY", 23.707), + ("RUB", 11.560694), + ("CHF", 0.120661), + ("INR", 14.248668), + ("VND", 3875.968992), + ("THB", 4.97107), + ] + .into_iter() + .map(|(currency, rate)| (currency.to_string(), rate)) + .collect() +} + +fn sanitize_rates(value: &Value) -> Option> { + let object = value.as_object()?; + let mut rates = BTreeMap::new(); + for (currency, value) in object { + let currency = currency.trim().to_ascii_uppercase(); + let rate = value.as_f64()?; + if currency.len() == 3 + && currency + .chars() + .all(|character| character.is_ascii_alphabetic()) + && rate.is_finite() + && (1e-12..=1e12).contains(&rate) + { + rates.insert(currency, rate); + } + } + rates.insert(BASE.to_string(), 1.0); + Some(rates) +} + +fn required_rates(rates: &BTreeMap) -> bool { + ["CNY", "USD", "CAD", "HKD", "EUR", "GBP", "JPY"] + .iter() + .all(|currency| rates.get(*currency).is_some_and(|rate| *rate > 0.0)) +} + +fn parse_er_api(value: &Value) -> Option { + if value.get("result")?.as_str()? != "success" + || !value.get("base_code")?.as_str()?.eq_ignore_ascii_case(BASE) + { + return None; + } + let timestamp = value.get("time_last_update_unix")?.as_i64()?; + let rates = sanitize_rates(value.get("rates")?)?; + if !required_rates(&rates) { + return None; + } + Some(FetchedRates { + rates, + source: "er-api", + date: date_from_timestamp(timestamp), + }) +} + +fn parse_frankfurter(value: &Value) -> Option { + if !value.get("base")?.as_str()?.eq_ignore_ascii_case(BASE) { + return None; + } + let date = value.get("date")?.as_str()?.to_string(); + if date.len() != 10 { + return None; + } + let rates = sanitize_rates(value.get("rates")?)?; + if !required_rates(&rates) { + return None; + } + Some(FetchedRates { + rates, + source: "frankfurter", + date, + }) +} + +fn date_from_timestamp(timestamp: i64) -> String { + time::OffsetDateTime::from_unix_timestamp(timestamp) + .map(|value| { + format!( + "{:04}-{:02}-{:02}", + value.year(), + value.month() as u8, + value.day() + ) + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_rates_cover_frontend_currencies() { + let rates = default_rates(); + for currency in [ + "CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD", + ] { + assert!(rates.contains_key(currency)); + } + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..d957eef --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,296 @@ +mod auth; +mod config; +mod db; +mod exchange; +mod middleware; +mod models; +mod notify; +mod routes; +mod theme; +mod totp; +mod turnstile; +mod websocket; + +use anyhow::{Context, Result}; +use axum::extract::DefaultBodyLimit; +use axum::middleware as axum_middleware; +use axum::routing::{delete, get, patch, post}; +use axum::Router; +use clap::Parser; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::{broadcast, RwLock}; +use tower_http::trace::TraceLayer; + +pub struct AppState { + pub db: db::Database, + pub config: config::Config, + pub http: reqwest::Client, + pub agents: RwLock>, + pub dashboard_tx: broadcast::Sender, +} + +impl AppState { + pub async fn push_agent_config(&self, server_id: &str) { + let Some(connection) = self.agents.read().await.get(server_id).cloned() else { + return; + }; + let config = match db::queries::agent_config(&self.db, server_id).await { + Ok(Some(config)) => config, + Ok(None) => return, + Err(error) => { + tracing::error!(%error, server_id, "failed to load Agent configuration"); + return; + } + }; + let payload = serde_json::json!({ + "type": "config", + "ts": db::now(), + "config": config, + }) + .to_string(); + let _ = connection + .sender + .send(websocket::AgentCommand::Text(payload)); + } + + pub async fn disconnect_agent(&self, server_id: &str) { + if let Some(connection) = self.agents.write().await.remove(server_id) { + let _ = connection.sender.send(websocket::AgentCommand::Close); + } + } + + pub async fn wake_agent(&self, server_id: &str) { + let Some(connection) = self.agents.read().await.get(server_id).cloned() else { + return; + }; + let payload = serde_json::json!({ + "type": "ack", + "ts": db::now(), + "persisted": false, + "persistenceError": false, + "persistedThroughTs": 0, + "nextD1WriteAfterMs": connection.report_interval.clamp(15, 3600) * 1000, + "nextWssReportAfterMs": connection.collect_interval.clamp(1, 60) * 1000, + "realtimeHint": true, + }) + .to_string(); + let _ = connection + .sender + .send(websocket::AgentCommand::Text(payload)); + } + + pub async fn send_remote_task(&self, task: &models::RemoteTaskInfo) -> bool { + self.agents + .read() + .await + .get(&task.server_id) + .is_some_and(|connection| websocket::agent::queue_remote_task(connection, task)) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "nodeflare_backend=info,tower_http=info".into()), + ) + .init(); + + let args = config::Args::parse(); + let mut config = config::Config::load(&args.config)?; + if let Some(database) = args.database { + config.database_url = database; + } + let bind_addr = args + .bind + .unwrap_or(config.bind_addr.parse().context("invalid bind_addr")?); + + let database = db::connect(&config.database_url).await?; + tracing::info!(database = ?database.kind(), "connected to database"); + database.migrate().await?; + db::initialize(&database, &config).await?; + + for (label, path) in [ + ("public frontend", &config.frontend_dir), + ("admin frontend", &config.admin_frontend_dir), + ("Agent installers", &config.agent_dir), + ] { + if !path.exists() { + tracing::warn!(label, path = %path.display(), "asset directory does not exist"); + } + } + + let http = reqwest::Client::builder() + .user_agent("NodeFlare-Standalone") + .connect_timeout(std::time::Duration::from_secs(5)) + .build()?; + let (dashboard_tx, _) = broadcast::channel(2048); + let state = Arc::new(AppState { + db: database, + config, + http, + agents: RwLock::new(HashMap::new()), + dashboard_tx, + }); + + let protected = Router::new() + .route("/api/admin/logout", post(routes::auth::logout)) + .route("/api/admin/2fa/status", get(routes::auth::get_2fa_status)) + .route("/api/admin/2fa/setup", post(routes::auth::setup_2fa)) + .route("/api/admin/2fa/enable", post(routes::auth::enable_2fa)) + .route("/api/admin/2fa/disable", post(routes::auth::disable_2fa)) + .route( + "/api/admin/servers", + get(routes::admin::servers_get) + .post(routes::admin::servers_post) + .delete(routes::admin::servers_delete), + ) + .route( + "/api/admin/servers/order", + patch(routes::admin::servers_order), + ) + .route( + "/api/admin/servers/:id/token", + get(routes::admin::server_token), + ) + .route( + "/api/admin/servers/:id", + patch(routes::admin::server_patch).delete(routes::admin::server_delete), + ) + .route( + "/api/admin/settings", + get(routes::admin::settings_get).patch(routes::admin::settings_patch), + ) + .route( + "/api/admin/latency-tasks", + get(routes::admin::latency_tasks_get).post(routes::admin::latency_tasks_post), + ) + .route( + "/api/admin/latency-tasks/:id", + patch(routes::admin::latency_task_patch).delete(routes::admin::latency_task_delete), + ) + .route( + "/api/admin/alert-rules", + get(routes::admin::alert_rules_get).post(routes::admin::alert_rules_post), + ) + .route( + "/api/admin/alert-rules/:id", + patch(routes::admin::alert_rule_patch).delete(routes::admin::alert_rule_delete), + ) + .route( + "/api/admin/telegram", + get(routes::admin::telegram_get).put(routes::admin::telegram_put), + ) + .route( + "/api/admin/telegram/test", + post(routes::admin::telegram_test), + ) + .route( + "/api/admin/themes", + get(routes::admin::themes_get).post(routes::admin::themes_post), + ) + .route( + "/api/admin/themes/:id/activate", + post(routes::admin::theme_activate), + ) + .route( + "/api/admin/themes/:id/preview", + post(routes::admin::theme_preview), + ) + .route("/api/admin/themes/:id", delete(routes::admin::theme_delete)) + .route( + "/api/admin/theme-settings", + get(routes::admin::theme_settings), + ) + .route( + "/api/admin/exchange-rates/refresh", + post(routes::admin::exchange_refresh), + ) + .route("/api/admin/database", get(routes::admin::database_stats)) + .route("/api/admin/history", delete(routes::admin::history_delete)) + .route("/api/admin/remote/task", post(routes::remote::create_task)) + .route("/api/admin/remote/task/:id", get(routes::remote::get_task)) + .route( + "/api/admin/remote/tasks/:server_id", + get(routes::remote::get_server_tasks), + ) + .layer(axum_middleware::from_fn_with_state( + Arc::clone(&state), + middleware::auth_middleware, + )); + + let app = Router::new() + .route("/api/bootstrap", get(routes::public::bootstrap)) + .route("/api/config", get(routes::public::config)) + .route("/api/servers", get(routes::public::servers)) + .route("/api/history/:id", get(routes::public::history)) + .route("/api/latency/:id", get(routes::public::latency_history)) + .route("/api/exchange-rates", get(routes::public::exchange_rates)) + .route("/api/live/wake", post(routes::public::wake_servers)) + .route( + "/api/turnstile/verify", + post(routes::auth::verify_turnstile), + ) + .route("/api/admin/login", post(routes::auth::login)) + .route("/api/agent/ws", get(websocket::agent::handle)) + .route("/api/ws", get(websocket::dashboard::handle)) + .merge(protected) + .fallback(routes::site::handle) + .layer(DefaultBodyLimit::max(2 * 1024 * 1024)) + .layer(TraceLayer::new_for_http()) + .layer(axum_middleware::from_fn(middleware::security_headers)) + .with_state(Arc::clone(&state)); + + spawn_maintenance(Arc::clone(&state)); + let listener = tokio::net::TcpListener::bind(bind_addr).await?; + tracing::info!(address = %bind_addr, "NodeFlare listening"); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +fn spawn_maintenance(state: Arc) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + if let Err(error) = db::cleanup_auth(&state.db).await { + tracing::error!(%error, "session cleanup failed"); + } + if let Err(error) = db::queries::cleanup_theme_previews(&state.db).await { + tracing::error!(%error, "theme preview cleanup failed"); + } + let settings = match db::load_settings(&state.db).await { + Ok(settings) => settings, + Err(error) => { + tracing::error!(%error, "maintenance settings load failed"); + continue; + } + }; + if let Err(error) = notify::run_periodic(&state.db, &state.http, &settings).await { + tracing::error!(%error, "notification maintenance failed"); + } + if let Err(error) = + db::queries::cleanup_history(&state.db, settings.history_retention_days).await + { + tracing::error!(%error, "history cleanup failed"); + } + if let Err(error) = exchange::refresh(&state.db, &state.http, false).await { + tracing::warn!(%error, "scheduled exchange-rate refresh failed"); + } + } + }); +} + +async fn shutdown_signal() { + if tokio::signal::ctrl_c().await.is_ok() { + tracing::info!("shutdown signal received"); + } +} diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs new file mode 100644 index 0000000..0abec1c --- /dev/null +++ b/backend/src/middleware.rs @@ -0,0 +1,63 @@ +use axum::{ + extract::{Request, State}, + http::{HeaderName, HeaderValue}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use std::sync::Arc; + +use crate::routes::{bearer_or_cookie, ApiResponse}; +use crate::AppState; + +pub async fn auth_middleware( + State(state): State>, + mut request: Request, + next: Next, +) -> Response { + let Some(token) = bearer_or_cookie(request.headers()) else { + return ApiResponse::unauthorized("请先登录").into_response(); + }; + match crate::db::session_username(&state.db, &token).await { + Ok(Some(username)) => { + request + .extensions_mut() + .insert(AuthenticatedUser { username }); + next.run(request).await + } + Ok(None) => ApiResponse::unauthorized("登录状态已过期").into_response(), + Err(error) => ApiResponse::internal(error).into_response(), + } +} + +#[derive(Clone)] +pub struct AuthenticatedUser { + pub username: String, +} + +pub async fn security_headers(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + headers.insert( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + ); + headers.insert( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + headers.insert( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + headers.insert( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static( + "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ws: wss:; frame-src https://challenges.cloudflare.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", + ), + ); + response +} diff --git a/src/models.rs b/backend/src/models.rs similarity index 65% rename from src/models.rs rename to backend/src/models.rs index ae571d0..f794237 100644 --- a/src/models.rs +++ b/backend/src/models.rs @@ -1,11 +1,5 @@ -use serde::{Deserialize, Serialize, Serializer}; - -fn serialize_sqlite_bool(value: &i64, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_bool(*value != 0) -} +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -13,7 +7,10 @@ pub struct LoginRequest { pub username: String, pub password: String, pub password_derived: String, + #[serde(default)] pub turnstile_token: String, + #[serde(default)] + pub totp_code: String, } #[derive(Debug, Deserialize)] @@ -22,7 +19,7 @@ pub struct TurnstileVerifyRequest { pub token: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ServerInput { pub name: String, @@ -48,7 +45,7 @@ pub struct ServerInput { pub auto_update: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct SettingsInput { pub site_name: Option, @@ -84,108 +81,54 @@ pub struct SettingsInput { pub offline_alert_minutes: Option, pub expiry_alert_days: Option, pub traffic_alert_percentage: Option, - pub cloudflare_account_id: Option, - pub cloudflare_api_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TelegramNotificationInput { - pub bot_token: String, - pub chat_id: String, - #[serde(default)] - pub message_thread_id: Option, - pub template: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ThemeInput { - pub name: String, - pub description: String, - pub url: String, } #[derive(Debug, Clone, Serialize)] -pub struct ThemeView { - pub id: String, - pub name: String, - pub description: String, - pub url: String, - pub version: String, - pub builtin: bool, - pub active: bool, +pub struct PublicConfig { + pub site_name: String, + pub site_description: String, + pub site_announcement: String, + pub logo_url: String, + pub locale: String, + pub public_dashboard: bool, + pub offline_threshold_seconds: i64, + pub history_retention_days: i64, + pub default_theme: String, + pub active_theme_id: String, + pub background_url: String, + pub theme_options: serde_json::Value, + pub show_search: bool, + pub show_groups: bool, + pub show_stats: bool, + pub show_assets: bool, + pub show_traffic: bool, + pub show_speed: bool, + pub show_price: bool, + pub show_expiry: bool, + pub show_latency: bool, + pub show_uptime: bool, + pub turnstile_enabled: bool, + pub turnstile_login_enabled: bool, + pub turnstile_site_key: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub password_client_salt: String, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerOrderInput { - pub ids: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerBatchInput { - pub ids: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LatencyTaskInput { - pub name: String, - pub task_type: String, - pub target: String, - pub port: Option, - pub interval_seconds: i64, - pub default_enabled: bool, - pub server_ids: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct AgentLatencyTask { - pub id: String, - pub name: String, - pub task_type: String, - pub target: String, - pub port: Option, - pub interval_seconds: i64, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct AgentLatencyResult { - pub task_id: String, - pub timestamp: i64, - pub latency_ms: f64, - pub packet_loss: f64, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct AgentDiskMetric { - pub name: String, - pub mount_point: String, - pub used: i64, - pub total: i64, - pub read_bps: f64, - pub write_bps: f64, - pub read_iops: f64, - pub write_iops: f64, - pub await_ms: f64, - pub utilization: f64, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct AgentGpuMetric { - pub model: String, - pub usage: Option, - pub memory_used: i64, - pub memory_total: i64, +#[derive(Debug, Clone, Serialize)] +pub struct SettingsView { + #[serde(flatten)] + pub public: PublicConfig, + pub admin_username: String, + pub admin_password_configured: bool, + pub turnstile_secret_key: String, + pub notification_enabled: bool, + pub offline_alert_minutes: i64, + pub expiry_alert_days: i64, + pub traffic_alert_percentage: i64, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(default, deny_unknown_fields)] pub struct AgentReport { pub timestamp: i64, pub cpu: f64, @@ -215,7 +158,6 @@ pub struct AgentReport { pub gpu_usage: f64, pub gpu_model: String, pub agent_version: String, - /// 双栈公网地址,agent 双栈探测的结果;空串表示当前不可用或探测尚未完成。 pub ip_v4: String, pub ip_v6: String, pub disk_read_bps: f64, @@ -229,26 +171,57 @@ pub struct AgentReport { pub latency_results: Vec, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct AgentDiskMetric { + pub name: String, + pub mount_point: String, + pub used: i64, + pub total: i64, + pub read_bps: f64, + pub write_bps: f64, + pub read_iops: f64, + pub write_iops: f64, + pub await_ms: f64, + pub utilization: f64, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct AgentGpuMetric { + pub model: String, + pub usage: Option, + pub memory_used: i64, + pub memory_total: i64, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct AgentLatencyResult { + pub task_id: String, + pub timestamp: i64, + pub latency_ms: f64, + pub packet_loss: f64, +} + +#[derive(Debug, Clone, Serialize)] pub struct ServerView { pub id: String, pub name: String, pub region: String, pub group_name: String, pub tags: String, - #[serde(serialize_with = "serialize_sqlite_bool")] - pub hidden: i64, + pub hidden: bool, pub expires_at: Option, pub traffic_limit: i64, pub traffic_limit_type: String, pub price: f64, pub billing_cycle: i64, pub currency: String, - #[serde(serialize_with = "serialize_sqlite_bool")] - pub auto_renewal: i64, + pub auto_renewal: bool, pub last_ip: String, - pub ip_v4: Option, - pub ip_v6: Option, + pub ip_v4: String, + pub ip_v6: String, pub network_interface: String, pub reset_day: i64, pub report_interval: i64, @@ -256,12 +229,8 @@ pub struct ServerView { pub rx_correction: i64, pub tx_correction: i64, pub agent_mirror: String, - #[serde(serialize_with = "serialize_sqlite_bool")] - pub offline_notify_disabled: i64, - #[serde(serialize_with = "serialize_sqlite_bool")] - pub auto_update: i64, - #[serde(skip_serializing)] - pub created_at: i64, + pub offline_notify_disabled: bool, + pub auto_update: bool, pub timestamp: Option, pub cpu: Option, pub load1: Option, @@ -296,13 +265,12 @@ pub struct ServerView { pub disk_write_iops: Option, pub disk_await_ms: Option, pub disk_utilization: Option, - #[serde(skip_serializing)] - pub disk_info: Option, - #[serde(skip_serializing)] - pub gpu_info: Option, + pub disks: Vec, + pub gpus: Vec, + pub latency: Vec, } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Serialize)] pub struct HistoryPoint { pub timestamp: i64, pub cpu: f64, @@ -331,23 +299,54 @@ pub struct HistoryPoint { pub disk_utilization: f64, } -#[derive(Debug, Deserialize)] -pub struct AgentIdentityRow { +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LatencyTaskInput { + pub name: String, + pub task_type: String, + pub target: String, + pub port: Option, + pub interval_seconds: i64, + pub default_enabled: bool, + pub server_ids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LatencyTaskView { pub id: String, - pub hidden: i64, + pub name: String, + pub task_type: String, + pub target: String, + pub port: Option, + pub interval_seconds: i64, + pub default_enabled: bool, + pub server_ids: Vec, } -#[derive(Debug, Serialize)] -pub struct AgentConfigView { - pub report_interval: i64, - pub collect_interval: i64, - pub network_interface: String, - pub agent_mirror: String, - pub auto_update: bool, - pub latency_tasks: Vec, +#[derive(Debug, Clone, Serialize)] +pub struct AgentLatencyTask { + pub id: String, + pub name: String, + pub task_type: String, + pub target: String, + pub port: Option, + pub interval_seconds: i64, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Default, Serialize)] +pub struct LatencySample { + pub task_id: String, + pub server_id: String, + pub name: String, + pub task_type: String, + pub target: String, + pub port: Option, + pub timestamp: i64, + pub latency_ms: f64, + pub packet_loss: f64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AlertRuleInput { pub name: String, @@ -359,7 +358,7 @@ pub struct AlertRuleInput { pub server_ids: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct AlertRuleView { pub id: String, pub name: String, @@ -371,9 +370,133 @@ pub struct AlertRuleView { pub server_ids: Vec, } -#[derive(Debug, Serialize)] -pub struct ApiError<'a> { - pub error: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option<&'a str>, +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TelegramSettingsInput { + pub bot_token: String, + pub chat_id: String, + #[serde(default)] + pub message_thread_id: Option, + pub template: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TelegramSettingsView { + pub bot_token: String, + pub chat_id: String, + pub message_thread_id: Option, + pub template: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ThemeInput { + pub name: String, + pub description: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThemeView { + pub id: String, + pub name: String, + pub description: String, + pub url: String, + pub version: String, + pub builtin: bool, + pub active: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerOrderInput { + pub ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerBatchInput { + pub ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WakeServersInput { + pub server_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Enable2FaRequest { + pub totp_code: String, +} + +#[derive(Debug, Serialize)] +pub struct TotpSetupResponse { + pub secret: String, + pub uri: String, + pub enabled: bool, +} + +#[derive(Debug, Serialize)] +pub struct TotpStatusResponse { + pub enabled: bool, + pub has_secret: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateRemoteTaskRequest { + pub server_id: String, + #[serde(default)] + pub command: String, + #[serde(default)] + pub script: String, + #[serde(default)] + pub totp_code: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RemoteTaskInfo { + pub id: String, + pub server_id: String, + pub command: String, + pub script: String, + pub status: String, + pub requested_by: String, + pub requested_at: i64, + pub started_at: Option, + pub completed_at: Option, + pub result: String, + pub exit_code: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DatabaseStats { + pub server_count: i64, + pub online_count: i64, + pub history_rows: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExchangeRatesView { + pub base: String, + pub rates: BTreeMap, + pub source: String, + pub date: String, + pub fetched_at: i64, + pub stale: bool, +} + +#[derive(Debug, Serialize)] +pub struct ApiError { + pub error: String, +} + +impl ApiError { + pub fn new(message: impl Into) -> Self { + Self { + error: message.into(), + } + } } diff --git a/backend/src/notify.rs b/backend/src/notify.rs new file mode 100644 index 0000000..f9ceafe --- /dev/null +++ b/backend/src/notify.rs @@ -0,0 +1,288 @@ +use crate::db::{self, queries, Database, Settings}; +use crate::models::TelegramSettingsView; +use anyhow::{Context, Result}; +use serde_json::Value; +use std::time::Duration; + +pub async fn test_telegram(db: &Database, client: &reqwest::Client) -> Result<()> { + send( + db, + client, + "NodeFlare 测试通知", + "NodeFlare", + "Telegram 通知配置有效。", + ) + .await +} + +pub async fn evaluate_resource_alerts( + db: &Database, + client: &reqwest::Client, + settings: &Settings, + server_id: &str, + server_name: &str, +) -> Result<()> { + if !settings.notification_enabled { + return Ok(()); + } + for evaluation in queries::evaluate_resource_rules(db, server_id).await? { + let key = format!("resource:{}:{server_id}", evaluation.rule.id); + let details = serde_json::json!({ + "value": evaluation.value, + "threshold": evaluation.rule.threshold, + "metric": evaluation.rule.metric, + }); + if !queries::update_alert_state(db, &key, evaluation.triggered, &details).await? { + continue; + } + let unit = if evaluation.rule.metric.starts_with("net_") { + "MiB/s" + } else { + "%" + }; + let (title, message) = if evaluation.triggered { + ( + "资源告警", + format!( + "{}:{:.2} {},阈值 {:.2} {}({} 分钟{})", + evaluation.rule.name, + evaluation.value, + unit, + evaluation.rule.threshold, + unit, + evaluation.rule.duration_minutes, + if evaluation.rule.aggregation == "continuous" { + "持续超限" + } else { + "平均值" + } + ), + ) + } else { + ( + "资源恢复", + format!( + "{} 已恢复,当前值 {:.2} {}", + evaluation.rule.name, evaluation.value, unit + ), + ) + }; + if let Err(error) = send(db, client, title, server_name, &message).await { + tracing::error!(%error, server_id, rule_id = %evaluation.rule.id, "resource alert notification failed"); + } + } + Ok(()) +} + +pub async fn run_periodic( + db: &Database, + client: &reqwest::Client, + settings: &Settings, +) -> Result<()> { + if !settings.notification_enabled { + return Ok(()); + } + let current = db::now(); + for server in queries::list_servers(db, true).await? { + if !server.offline_notify_disabled { + let offline = server.timestamp.is_none_or(|timestamp| { + current.saturating_sub(timestamp) >= settings.offline_alert_minutes * 60 + }); + let key = format!("offline:{}", server.id); + if queries::update_alert_state( + db, + &key, + offline, + &serde_json::json!({"timestamp": server.timestamp}), + ) + .await? + { + let (title, message) = if offline { + ( + "服务器离线", + format!( + "超过 {} 分钟未收到 Agent 上报", + settings.offline_alert_minutes + ), + ) + } else { + ("服务器恢复在线", "已重新收到 Agent 上报".to_string()) + }; + notify_best_effort(db, client, title, &server.name, &message).await; + } + } + + if let Some(expires_at) = server.expires_at { + let expiring = settings.expiry_alert_days > 0 + && expires_at >= current + && expires_at - current <= settings.expiry_alert_days * 86_400; + let key = format!("expiry:{}", server.id); + if queries::update_alert_state( + db, + &key, + expiring, + &serde_json::json!({"expires_at": expires_at}), + ) + .await? + && expiring + { + let days = (expires_at - current + 86_399) / 86_400; + notify_best_effort( + db, + client, + "服务器即将到期", + &server.name, + &format!("预计还有 {days} 天到期"), + ) + .await; + } + } + + if server.traffic_limit > 0 { + let rx = server.net_rx_total.unwrap_or_default().max(0); + let tx = server.net_tx_total.unwrap_or_default().max(0); + let used = match server.traffic_limit_type.as_str() { + "max" => rx.max(tx), + "min" => rx.min(tx), + "up" => tx, + "down" => rx, + _ => rx.saturating_add(tx), + }; + let percentage = used as f64 / server.traffic_limit as f64 * 100.0; + for milestone in traffic_milestones(settings.traffic_alert_percentage) { + let exceeded = percentage >= milestone as f64; + let key = format!("traffic:{}:{milestone}", server.id); + if queries::update_alert_state( + db, + &key, + exceeded, + &serde_json::json!({"percentage": percentage, "milestone": milestone}), + ) + .await? + && exceeded + { + notify_best_effort( + db, + client, + "流量告警", + &server.name, + &format!( + "当前周期已使用 {:.1}% 的流量额度(达到 {milestone}% 提醒线)", + percentage + ), + ) + .await; + } + } + } + } + Ok(()) +} + +fn traffic_milestones(start: i64) -> Vec { + let mut milestones = vec![start.clamp(50, 100)]; + while *milestones.last().unwrap_or(&100) < 100 { + let next = (*milestones.last().unwrap_or(&100) + 5).min(100); + milestones.push(next); + } + milestones +} + +async fn notify_best_effort( + db: &Database, + client: &reqwest::Client, + title: &str, + server: &str, + message: &str, +) { + if let Err(error) = send(db, client, title, server, message).await { + tracing::error!(%error, title, server, "Telegram notification failed"); + } +} + +async fn send( + db: &Database, + client: &reqwest::Client, + title: &str, + server: &str, + message: &str, +) -> Result<()> { + let settings = queries::raw_telegram_settings(db) + .await? + .context("尚未配置 Telegram")?; + validate_settings(&settings)?; + let text = render(&settings.template, title, server, message); + let mut payload = serde_json::json!({ + "chat_id": settings.chat_id, + "text": text, + "disable_web_page_preview": true, + }); + if let Some(thread_id) = settings.message_thread_id { + payload["message_thread_id"] = Value::from(thread_id); + } + let response = client + .post(format!( + "https://api.telegram.org/bot{}/sendMessage", + settings.bot_token + )) + .timeout(Duration::from_secs(10)) + .json(&payload) + .send() + .await? + .error_for_status()?; + let body = response.bytes().await?; + if body.len() > 64 * 1024 { + anyhow::bail!("Telegram response is too large"); + } + let value: Value = serde_json::from_slice(&body)?; + if value.get("ok").and_then(Value::as_bool) != Some(true) { + anyhow::bail!("Telegram rejected the message"); + } + Ok(()) +} + +fn validate_settings(settings: &TelegramSettingsView) -> Result<()> { + if settings.bot_token.trim().is_empty() + || settings.bot_token.len() > 512 + || settings.bot_token.chars().any(char::is_whitespace) + { + anyhow::bail!("Telegram Bot Token 无效"); + } + if settings.chat_id.trim().is_empty() || settings.chat_id.len() > 128 { + anyhow::bail!("Telegram Chat ID 无效"); + } + if settings.template.trim().is_empty() || settings.template.chars().count() > 4000 { + anyhow::bail!("Telegram 消息模板无效"); + } + Ok(()) +} + +fn render(template: &str, title: &str, server: &str, message: &str) -> String { + let timestamp = time::OffsetDateTime::now_utc(); + let time = format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + timestamp.year(), + timestamp.month() as u8, + timestamp.day(), + timestamp.hour(), + timestamp.minute(), + timestamp.second() + ); + template + .replace("{{title}}", title) + .replace("{{server}}", server) + .replace("{{message}}", message) + .replace("{{time}}", &time) +} + +#[cfg(test)] +mod tests { + use super::traffic_milestones; + + #[test] + fn traffic_alerts_advance_in_five_percent_steps() { + assert_eq!(traffic_milestones(80), vec![80, 85, 90, 95, 100]); + assert_eq!(traffic_milestones(98), vec![98, 100]); + assert_eq!(traffic_milestones(100), vec![100]); + } +} diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs new file mode 100644 index 0000000..fa75b11 --- /dev/null +++ b/backend/src/routes/admin.rs @@ -0,0 +1,821 @@ +use super::{admin_cookie, request_is_secure, ApiResponse}; +use crate::db::SECRET_MASK; +use crate::middleware::AuthenticatedUser; +use crate::models::{ + AlertRuleInput, LatencyTaskInput, ServerBatchInput, ServerInput, ServerOrderInput, + SettingsInput, TelegramSettingsInput, ThemeInput, +}; +use crate::AppState; +use axum::extract::{Extension, Path, State}; +use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::net::Ipv4Addr; +use std::sync::Arc; +use url::Url; + +pub async fn servers_get(State(state): State>) -> Result { + let servers = crate::db::queries::list_servers(&state.db, true) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"servers": servers})).into_response()) +} + +pub async fn servers_post( + State(state): State>, + Json(input): Json, +) -> Result { + validate_server(&input).map_err(ApiResponse::bad_request)?; + let (id, token) = crate::db::queries::create_server(&state.db, &input) + .await + .map_err(ApiResponse::internal)?; + Ok(( + StatusCode::CREATED, + Json(serde_json::json!({"id": id, "agent_token": token})), + ) + .into_response()) +} + +pub async fn server_patch( + State(state): State>, + Path(id): Path, + Json(input): Json, +) -> Result { + validate_id(&id)?; + validate_server(&input).map_err(ApiResponse::bad_request)?; + if !crate::db::queries::update_server(&state.db, &id, &input) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("节点不存在")); + } + state.push_agent_config(&id).await; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn server_delete( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + if !crate::db::queries::delete_server(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("节点不存在")); + } + state.disconnect_agent(&id).await; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn servers_delete( + State(state): State>, + Json(input): Json, +) -> Result { + validate_ids(&input.ids)?; + crate::db::queries::delete_servers(&state.db, &input.ids) + .await + .map_err(ApiResponse::internal)?; + for id in input.ids { + state.disconnect_agent(&id).await; + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn servers_order( + State(state): State>, + Json(input): Json, +) -> Result { + validate_ids(&input.ids)?; + let current = crate::db::queries::all_server_ids(&state.db) + .await + .map_err(ApiResponse::internal)?; + if current.len() != input.ids.len() || input.ids.iter().any(|id| !current.contains(id)) { + return Err(ApiResponse::bad_request("排序列表必须包含全部节点")); + } + crate::db::queries::reorder_servers(&state.db, &input.ids) + .await + .map_err(ApiResponse::internal)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn server_token( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + let token = crate::db::queries::server_token(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::not_found("节点不存在"))?; + Ok(Json(serde_json::json!({"agent_token": token})).into_response()) +} + +pub async fn settings_get(State(state): State>) -> Result { + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(settings.admin_view()).into_response()) +} + +pub async fn settings_patch( + State(state): State>, + Extension(user): Extension, + headers: HeaderMap, + Json(mut input): Json, +) -> Result { + let current = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + validate_settings(&state, ¤t, &input).await?; + let password_hash = match input + .new_password + .as_deref() + .filter(|value| !value.is_empty()) + { + Some(_) => Some( + crate::auth::hash_password(input.new_password_derived.as_deref().unwrap_or_default()) + .map_err(ApiResponse::internal)?, + ), + None => None, + }; + let next_username = input + .admin_username + .as_deref() + .map(str::trim) + .unwrap_or(¤t.admin_username) + .to_string(); + let username_changed = next_username != user.username; + if !username_changed { + input.admin_username = None; + } + crate::db::update_settings(&state.db, &input, password_hash.as_deref()) + .await + .map_err(ApiResponse::internal)?; + crate::db::queries::rename_totp_user(&state.db, &user.username, &next_username) + .await + .map_err(ApiResponse::internal)?; + let credentials_changed = password_hash.is_some() || username_changed; + let token = if credentials_changed { + Some( + crate::db::create_session(&state.db, &next_username, state.config.session_ttl_hours) + .await + .map_err(ApiResponse::internal)?, + ) + } else { + None + }; + let updated = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + let mut response = Json(serde_json::json!({ + "settings": updated.admin_view(), + "token": token, + })) + .into_response(); + if let Some(token) = token { + response.headers_mut().append( + SET_COOKIE, + HeaderValue::from_str(&admin_cookie( + &token, + state.config.session_ttl_hours * 3600, + request_is_secure(&headers), + )) + .map_err(ApiResponse::internal)?, + ); + } + Ok(response) +} + +pub async fn latency_tasks_get( + State(state): State>, +) -> Result { + let tasks = crate::db::queries::list_latency_tasks(&state.db) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"tasks": tasks})).into_response()) +} + +pub async fn latency_tasks_post( + State(state): State>, + Json(input): Json, +) -> Result { + validate_latency_task(&state, &input).await?; + if crate::db::queries::list_latency_tasks(&state.db) + .await + .map_err(ApiResponse::internal)? + .len() + >= 128 + { + return Err(ApiResponse::bad_request("最多可创建 128 个延迟任务")); + } + let id = crate::db::queries::create_latency_task(&state.db, &input) + .await + .map_err(ApiResponse::internal)?; + for server_id in &input.server_ids { + state.push_agent_config(server_id).await; + } + Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response()) +} + +pub async fn latency_task_patch( + State(state): State>, + Path(id): Path, + Json(input): Json, +) -> Result { + validate_id(&id)?; + validate_latency_task(&state, &input).await?; + let mut affected = crate::db::queries::latency_task_server_ids(&state.db, &id) + .await + .map_err(ApiResponse::internal)?; + affected.extend(input.server_ids.iter().cloned()); + affected.sort(); + affected.dedup(); + if !crate::db::queries::update_latency_task(&state.db, &id, &input) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("延迟任务不存在")); + } + for server_id in affected { + state.push_agent_config(&server_id).await; + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn latency_task_delete( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + let affected = crate::db::queries::latency_task_server_ids(&state.db, &id) + .await + .map_err(ApiResponse::internal)?; + if !crate::db::queries::delete_latency_task(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("延迟任务不存在")); + } + for server_id in affected { + state.push_agent_config(&server_id).await; + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn alert_rules_get(State(state): State>) -> Result { + let rules = crate::db::queries::list_alert_rules(&state.db) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"rules": rules})).into_response()) +} + +pub async fn alert_rules_post( + State(state): State>, + Json(input): Json, +) -> Result { + validate_alert_rule(&state, &input).await?; + if crate::db::queries::list_alert_rules(&state.db) + .await + .map_err(ApiResponse::internal)? + .len() + >= 20 + { + return Err(ApiResponse::bad_request("最多可创建 20 条资源告警规则")); + } + let id = crate::db::queries::create_alert_rule(&state.db, &input) + .await + .map_err(ApiResponse::internal)?; + Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response()) +} + +pub async fn alert_rule_patch( + State(state): State>, + Path(id): Path, + Json(input): Json, +) -> Result { + validate_id(&id)?; + validate_alert_rule(&state, &input).await?; + if !crate::db::queries::update_alert_rule(&state.db, &id, &input) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("告警规则不存在")); + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn alert_rule_delete( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + if !crate::db::queries::delete_alert_rule(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("告警规则不存在")); + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn telegram_get(State(state): State>) -> Result { + let telegram = crate::db::queries::telegram_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"telegram": telegram})).into_response()) +} + +pub async fn telegram_put( + State(state): State>, + Json(input): Json, +) -> Result { + if input.bot_token.trim().is_empty() + || input.bot_token.len() > 512 + || input.bot_token.chars().any(char::is_whitespace) + || input.chat_id.trim().is_empty() + || input.chat_id.len() > 128 + || input.template.trim().is_empty() + || input.template.chars().count() > 4000 + || input.message_thread_id.is_some_and(|value| value <= 0) + { + return Err(ApiResponse::bad_request("Telegram 配置格式无效")); + } + crate::db::queries::save_telegram_settings(&state.db, &input) + .await + .map_err(ApiResponse::internal)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn telegram_test(State(state): State>) -> Result { + crate::notify::test_telegram(&state.db, &state.http) + .await + .map_err(|error| ApiResponse::error(StatusCode::BAD_GATEWAY, error.to_string()))?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn themes_get(State(state): State>) -> Result { + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + let themes = crate::db::queries::list_themes(&state.db, &settings.active_theme_id) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"themes": themes})).into_response()) +} + +pub async fn themes_post( + State(state): State>, + Json(input): Json, +) -> Result { + if !(1..=80).contains(&input.name.trim().chars().count()) + || input.description.trim().chars().count() > 300 + { + return Err(ApiResponse::bad_request("主题名称或说明无效")); + } + let resolved = crate::theme::resolve_url(&input.url) + .map_err(|error| ApiResponse::bad_request(error.to_string()))?; + let digest = Sha256::digest(resolved.source_url.as_bytes()); + let id = format!("theme-{}", &hex::encode(digest)[..16]); + if crate::db::queries::theme_exists(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::conflict("该主题已添加")); + } + crate::theme::validate_remote(&state.http, &resolved.resolved_url) + .await + .map_err(|error| ApiResponse::unprocessable(error.to_string()))?; + let version = crate::theme::remote_version(&state.http, &resolved.resolved_url) + .await + .unwrap_or_default(); + crate::db::queries::create_theme( + &state.db, + &id, + &ThemeInput { + url: resolved.source_url, + ..input + }, + &resolved.resolved_url, + &version, + ) + .await + .map_err(ApiResponse::internal)?; + Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response()) +} + +pub async fn theme_activate( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + if id != crate::theme::BUILTIN_THEME_ID { + let url = crate::db::queries::theme_resolved_url(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::not_found("主题不存在"))?; + crate::theme::validate_remote(&state.http, &url) + .await + .map_err(|error| ApiResponse::unprocessable(error.to_string()))?; + } + if !crate::db::queries::set_active_theme(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("主题不存在")); + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn theme_preview( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + if id == crate::theme::BUILTIN_THEME_ID + || !crate::db::queries::theme_exists(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("远程主题不存在")); + } + let proof = crate::db::queries::create_theme_preview(&state.db, &id) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({ + "preview_url": format!("/__theme-preview/{proof}/") + })) + .into_response()) +} + +pub async fn theme_delete( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + if id == crate::theme::BUILTIN_THEME_ID { + return Err(ApiResponse::bad_request("内置主题不能删除")); + } + if !crate::db::queries::delete_theme(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("主题不存在")); + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn theme_settings(State(state): State>) -> Result { + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + if settings.active_theme_id == crate::theme::BUILTIN_THEME_ID { + return Ok(Json(crate::theme::builtin_settings_schema()).into_response()); + } + let url = crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::not_found("当前主题不存在"))?; + Ok(Json( + crate::theme::remote_settings_schema(&state.http, &url) + .await + .map_err(|error| ApiResponse::unprocessable(error.to_string()))?, + ) + .into_response()) +} + +pub async fn exchange_refresh(State(state): State>) -> Result { + Ok(Json( + crate::exchange::refresh(&state.db, &state.http, true) + .await + .map_err(|error| ApiResponse::error(StatusCode::BAD_GATEWAY, error.to_string()))?, + ) + .into_response()) +} + +pub async fn database_stats(State(state): State>) -> Result { + let threshold = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)? + .offline_threshold_seconds; + Ok(Json( + crate::db::queries::database_stats(&state.db, threshold) + .await + .map_err(ApiResponse::internal)?, + ) + .into_response()) +} + +pub async fn history_delete(State(state): State>) -> Result { + crate::db::queries::clear_history(&state.db) + .await + .map_err(ApiResponse::internal)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +fn validate_id(id: &str) -> Result<(), ApiResponse> { + if id.is_empty() || id.len() > 80 || id.contains('/') { + Err(ApiResponse::bad_request("ID 无效")) + } else { + Ok(()) + } +} + +fn validate_ids(ids: &[String]) -> Result<(), ApiResponse> { + if ids.is_empty() + || ids.len() > 500 + || ids.iter().any(|id| validate_id(id).is_err()) + || ids.iter().collect::>().len() != ids.len() + { + Err(ApiResponse::bad_request("节点列表无效")) + } else { + Ok(()) + } +} + +fn validate_server(input: &ServerInput) -> Result<(), &'static str> { + if !(1..=80).contains(&input.name.trim().chars().count()) { + return Err("节点名称长度应为 1 至 80 个字符"); + } + if input.region.chars().count() > 16 + || input.group_name.chars().count() > 40 + || input.tags.chars().count() > 240 + { + return Err("地区、分组或标签字段过长"); + } + if input.traffic_limit < 0 + || !matches!( + input.traffic_limit_type.as_str(), + "sum" | "max" | "min" | "up" | "down" + ) + { + return Err("流量配置无效"); + } + if !input.price.is_finite() + || !(-1.0..=1_000_000_000.0).contains(&input.price) + || !(0..=3650).contains(&input.billing_cycle) + { + return Err("价格或计费周期无效"); + } + if input.currency.len() != 3 + || !input + .currency + .chars() + .all(|value| value.is_ascii_alphabetic()) + { + return Err("币种应为 3 位字母代码"); + } + if !(1..=31).contains(&input.reset_day) + || !(15..=3600).contains(&input.report_interval) + || !(1..=60).contains(&input.collect_interval) + || input.collect_interval > input.report_interval + || (input.report_interval + input.collect_interval - 1) / input.collect_interval > 720 + { + return Err("采样、上报或流量重置配置无效"); + } + if input.network_interface.chars().count() > 160 || !valid_agent_mirror(&input.agent_mirror) { + return Err("网卡或 Agent 下载地址无效"); + } + Ok(()) +} + +async fn validate_latency_task( + state: &AppState, + input: &LatencyTaskInput, +) -> Result<(), ApiResponse> { + if !(1..=80).contains(&input.name.trim().chars().count()) + || !matches!(input.task_type.as_str(), "tcp" | "icmp") + || !valid_ping_target(input.target.trim()) + || !(30..=3600).contains(&input.interval_seconds) + || input.task_type == "tcp" && input.port.is_none_or(|port| !(1..=65535).contains(&port)) + || input.task_type == "icmp" && input.port.is_some() + { + return Err(ApiResponse::bad_request("延迟任务格式无效")); + } + validate_ids_allow_empty(&input.server_ids)?; + let servers = crate::db::queries::all_server_ids(&state.db) + .await + .map_err(ApiResponse::internal)?; + if input.server_ids.iter().any(|id| !servers.contains(id)) { + return Err(ApiResponse::bad_request("延迟任务包含不存在的节点")); + } + Ok(()) +} + +async fn validate_alert_rule(state: &AppState, input: &AlertRuleInput) -> Result<(), ApiResponse> { + let maximum = if matches!(input.metric.as_str(), "cpu" | "memory" | "disk") { + 100.0 + } else { + 1_000_000.0 + }; + if !(1..=80).contains(&input.name.trim().chars().count()) + || !matches!( + input.metric.as_str(), + "cpu" | "memory" | "disk" | "net_in" | "net_out" + ) + || !input.threshold.is_finite() + || input.threshold <= 0.0 + || input.threshold > maximum + || !(1..=1440).contains(&input.duration_minutes) + || !matches!(input.aggregation.as_str(), "average" | "continuous") + { + return Err(ApiResponse::bad_request("告警规则格式无效")); + } + validate_ids_allow_empty(&input.server_ids)?; + let servers = crate::db::queries::all_server_ids(&state.db) + .await + .map_err(ApiResponse::internal)?; + if input.server_ids.iter().any(|id| !servers.contains(id)) { + return Err(ApiResponse::bad_request("告警规则包含不存在的节点")); + } + Ok(()) +} + +fn validate_ids_allow_empty(ids: &[String]) -> Result<(), ApiResponse> { + if ids.len() > 500 + || ids.iter().any(|id| validate_id(id).is_err()) + || ids.iter().collect::>().len() != ids.len() + { + Err(ApiResponse::bad_request("节点选择列表无效")) + } else { + Ok(()) + } +} + +async fn validate_settings( + state: &AppState, + current: &crate::db::Settings, + input: &SettingsInput, +) -> Result<(), ApiResponse> { + if input + .site_name + .as_ref() + .is_some_and(|value| value.trim().is_empty() || value.chars().count() > 80) + || input + .site_description + .as_ref() + .is_some_and(|value| value.chars().count() > 240) + || input + .site_announcement + .as_ref() + .is_some_and(|value| value.chars().count() > 1000) + || input + .logo_url + .as_deref() + .is_some_and(|value| !valid_https_url(value)) + || input + .background_url + .as_deref() + .is_some_and(|value| !valid_background_urls(value)) + || input + .locale + .as_deref() + .is_some_and(|value| !matches!(value, "zh-CN" | "en")) + || input + .default_theme + .as_deref() + .is_some_and(|value| !matches!(value, "system" | "light" | "dark")) + { + return Err(ApiResponse::bad_request("站点设置格式无效")); + } + if input.admin_username.as_ref().is_some_and(|value| { + value.trim().is_empty() + || value.chars().count() > 64 + || value.chars().any(char::is_whitespace) + }) { + return Err(ApiResponse::bad_request("管理员用户名格式无效")); + } + if input + .new_password + .as_ref() + .is_some_and(|value| !value.is_empty() && !(8..=128).contains(&value.chars().count())) + || input + .new_password + .as_ref() + .is_some_and(|value| !value.is_empty()) + && input + .new_password_derived + .as_deref() + .is_none_or(|value| !crate::auth::valid_password_derived(value)) + { + return Err(ApiResponse::bad_request("新密码格式无效")); + } + if input.theme_options.as_ref().is_some_and(|value| { + let Some(object) = value.as_object() else { + return true; + }; + object.len() > 40 || value.to_string().len() > 12_000 + }) { + return Err(ApiResponse::bad_request("主题设置格式无效")); + } + if let Some(id) = input.active_theme_id.as_deref() { + if id != crate::theme::BUILTIN_THEME_ID + && !crate::db::queries::theme_exists(&state.db, id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::bad_request("活动主题不存在")); + } + } + let site_key = submitted_secret( + input.turnstile_site_key.as_deref(), + ¤t.turnstile_site_key, + ); + let secret_key = submitted_secret( + input.turnstile_secret_key.as_deref(), + ¤t.turnstile_secret_key, + ); + if site_key.is_empty() != secret_key.is_empty() { + return Err(ApiResponse::bad_request( + "Turnstile Site Key 和 Secret Key 必须同时填写或同时留空", + )); + } + Ok(()) +} + +fn submitted_secret<'a>(submitted: Option<&'a str>, current: &'a str) -> &'a str { + match submitted.map(str::trim) { + None | Some(SECRET_MASK) => current, + Some(value) => value, + } +} + +fn valid_https_url(value: &str) -> bool { + let value = value.trim(); + value.is_empty() + || value.len() <= 1000 + && Url::parse(value).is_ok_and(|url| { + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + }) +} + +fn valid_background_urls(value: &str) -> bool { + let parts = value.split('|').collect::>(); + value.trim().is_empty() + || parts.len() <= 2 + && parts.iter().any(|part| !part.trim().is_empty()) + && parts.iter().all(|part| valid_https_url(part)) +} + +fn valid_agent_mirror(value: &str) -> bool { + let value = value.trim().trim_end_matches('/'); + if value.is_empty() { + return true; + } + Url::parse(value).is_ok_and(|url| { + url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && (url.scheme() == "https" + || url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1"))) + }) +} + +fn valid_ping_target(value: &str) -> bool { + let labels = value.split('.').collect::>(); + if labels.len() == 4 + && labels + .iter() + .all(|part| part.chars().all(|c| c.is_ascii_digit())) + { + return value.parse::().is_ok_and(|ip| { + let [a, b, c, _] = ip.octets(); + !(a == 0 + || a == 10 + || a == 127 + || a == 169 && b == 254 + || a == 172 && (16..=31).contains(&b) + || a == 192 && b == 168 + || a == 192 && b == 0 && (c == 0 || c == 2) + || a >= 224) + }); + } + let lower = value.to_ascii_lowercase(); + labels.len() >= 2 + && !["local", "localhost", "internal", "lan", "localdomain"] + .iter() + .any(|suffix| lower == *suffix || lower.ends_with(&format!(".{suffix}"))) + && labels.iter().all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + && label + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && label + .chars() + .last() + .is_some_and(|character| character.is_ascii_alphanumeric()) + }) +} diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 0000000..135a4b9 --- /dev/null +++ b/backend/src/routes/auth.rs @@ -0,0 +1,238 @@ +use super::{ + admin_cookie, bearer_or_cookie, forwarded_ip, hostname, request_is_secure, ApiResponse, +}; +use crate::middleware::AuthenticatedUser; +use crate::models::{ + Enable2FaRequest, LoginRequest, TotpSetupResponse, TotpStatusResponse, TurnstileVerifyRequest, +}; +use crate::AppState; +use axum::extract::{Extension, State}; +use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Serialize)] +struct LoginResponse { + token: String, +} + +pub async fn login( + State(state): State>, + headers: HeaderMap, + Json(input): Json, +) -> Result { + if input.username.trim().is_empty() + || input.password.is_empty() + || !crate::auth::valid_password_derived(&input.password_derived) + { + return Err(ApiResponse::bad_request("请输入用户名和密码")); + } + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + let public = settings.public_config(); + if public.turnstile_login_enabled { + let host = hostname(&headers).ok_or_else(|| ApiResponse::bad_request("请求主机名无效"))?; + let valid = crate::turnstile::verify( + &state.http, + &input.turnstile_token, + &settings.turnstile_secret_key, + forwarded_ip(&headers).as_deref(), + &host, + "admin_login", + ) + .await; + if !valid { + return Err(ApiResponse::forbidden("Cloudflare 人机验证失败,请重试")); + } + } + if !secure_eq(input.username.trim(), settings.admin_username.trim()) + || !crate::auth::verify_password(&input.password_derived, &settings.admin_password_hash) + { + return Err(ApiResponse::unauthorized("用户名或密码错误")); + } + if let Some((secret, enabled)) = + crate::db::queries::get_totp_secret(&state.db, &settings.admin_username) + .await + .map_err(ApiResponse::internal)? + { + if enabled { + if input.totp_code.trim().is_empty() { + return Err(ApiResponse::error( + StatusCode::PRECONDITION_REQUIRED, + "请输入两步验证码", + )); + } + if !crate::totp::verify_totp(&secret, input.totp_code.trim()) + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::unauthorized("两步验证码错误")); + } + } + } + let token = crate::db::create_session( + &state.db, + &settings.admin_username, + state.config.session_ttl_hours, + ) + .await + .map_err(ApiResponse::internal)?; + let mut response = Json(LoginResponse { + token: token.clone(), + }) + .into_response(); + response.headers_mut().append( + SET_COOKIE, + HeaderValue::from_str(&admin_cookie( + &token, + state.config.session_ttl_hours * 3600, + request_is_secure(&headers), + )) + .map_err(ApiResponse::internal)?, + ); + Ok(response) +} + +pub async fn logout( + State(state): State>, + headers: HeaderMap, +) -> Result { + if let Some(token) = bearer_or_cookie(&headers) { + crate::db::revoke_session(&state.db, &token) + .await + .map_err(ApiResponse::internal)?; + } + let mut response = StatusCode::NO_CONTENT.into_response(); + response.headers_mut().append( + SET_COOKIE, + HeaderValue::from_str(&admin_cookie("", 0, request_is_secure(&headers))) + .map_err(ApiResponse::internal)?, + ); + Ok(response) +} + +pub async fn verify_turnstile( + State(state): State>, + headers: HeaderMap, + Json(input): Json, +) -> Result { + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + if !settings.public_config().turnstile_enabled { + return Err(ApiResponse::bad_request("公开仪表盘未启用人机验证")); + } + let host = hostname(&headers).ok_or_else(|| ApiResponse::bad_request("请求主机名无效"))?; + if !crate::turnstile::verify( + &state.http, + &input.token, + &settings.turnstile_secret_key, + forwarded_ip(&headers).as_deref(), + &host, + "public_dashboard", + ) + .await + { + return Err(ApiResponse::forbidden("Cloudflare 人机验证失败,请重试")); + } + let proof = crate::db::create_dashboard_proof(&state.db) + .await + .map_err(ApiResponse::internal)?; + let mut response = StatusCode::NO_CONTENT.into_response(); + let secure = if request_is_secure(&headers) { + "; Secure" + } else { + "" + }; + response.headers_mut().append( + SET_COOKIE, + HeaderValue::from_str(&format!( + "nodeflare_turnstile={proof}; Path=/; Max-Age=3600; HttpOnly; SameSite=Strict{secure}" + )) + .map_err(ApiResponse::internal)?, + ); + Ok(response) +} + +pub async fn get_2fa_status( + State(state): State>, + Extension(user): Extension, +) -> Result, ApiResponse> { + let status = crate::db::queries::get_totp_secret(&state.db, &user.username) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(TotpStatusResponse { + enabled: status.as_ref().is_some_and(|(_, enabled)| *enabled), + has_secret: status.is_some(), + })) +} + +pub async fn setup_2fa( + State(state): State>, + Extension(user): Extension, +) -> Result, ApiResponse> { + if crate::db::queries::get_totp_secret(&state.db, &user.username) + .await + .map_err(ApiResponse::internal)? + .is_some_and(|(_, enabled)| enabled) + { + return Err(ApiResponse::conflict( + "请先使用当前验证码禁用两步验证,再重新生成密钥", + )); + } + let secret = crate::totp::generate_secret(); + let uri = crate::totp::generate_uri(&secret, "NodeFlare", &user.username); + crate::db::queries::save_totp_secret(&state.db, &user.username, &secret, false) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(TotpSetupResponse { + secret, + uri, + enabled: false, + })) +} + +pub async fn enable_2fa( + State(state): State>, + Extension(user): Extension, + Json(input): Json, +) -> Result { + let (secret, _) = crate::db::queries::get_totp_secret(&state.db, &user.username) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::bad_request("请先生成两步验证密钥"))?; + if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? { + return Err(ApiResponse::bad_request("两步验证码错误")); + } + crate::db::queries::set_totp_enabled(&state.db, &user.username, true) + .await + .map_err(ApiResponse::internal)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn disable_2fa( + State(state): State>, + Extension(user): Extension, + Json(input): Json, +) -> Result { + let (secret, enabled) = crate::db::queries::get_totp_secret(&state.db, &user.username) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::bad_request("尚未配置两步验证"))?; + if !enabled { + return Ok(StatusCode::NO_CONTENT.into_response()); + } + if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? { + return Err(ApiResponse::bad_request("两步验证码错误")); + } + crate::db::queries::set_totp_enabled(&state.db, &user.username, false) + .await + .map_err(ApiResponse::internal)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +fn secure_eq(left: &str, right: &str) -> bool { + crate::auth::token_hash(left) == crate::auth::token_hash(right) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..55a53fb --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,120 @@ +pub mod admin; +pub mod auth; +pub mod public; +pub mod remote; +pub mod site; + +use crate::models::ApiError; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; + +#[derive(Debug)] +pub struct ApiResponse { + status: StatusCode, + message: String, +} + +impl ApiResponse { + pub fn error(status: StatusCode, message: impl Into) -> Self { + Self { + status, + message: message.into(), + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::error(StatusCode::BAD_REQUEST, message) + } + + pub fn unauthorized(message: impl Into) -> Self { + Self::error(StatusCode::UNAUTHORIZED, message) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::error(StatusCode::FORBIDDEN, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::error(StatusCode::NOT_FOUND, message) + } + + pub fn conflict(message: impl Into) -> Self { + Self::error(StatusCode::CONFLICT, message) + } + + pub fn unprocessable(message: impl Into) -> Self { + Self::error(StatusCode::UNPROCESSABLE_ENTITY, message) + } + + pub fn internal(error: impl std::fmt::Display) -> Self { + tracing::error!(error = %error, "request failed"); + Self::error(StatusCode::INTERNAL_SERVER_ERROR, "服务器内部错误") + } +} + +impl IntoResponse for ApiResponse { + fn into_response(self) -> Response { + (self.status, Json(ApiError::new(self.message))).into_response() + } +} + +pub fn bearer_or_cookie(headers: &HeaderMap) -> Option { + if let Some(value) = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| !value.is_empty()) + { + return Some(value.to_string()); + } + cookie(headers, "nodeflare_admin") +} + +pub fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get(axum::http::header::COOKIE)? + .to_str() + .ok()? + .split(';') + .map(str::trim) + .find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then(|| value.to_string()) + }) +} + +pub fn hostname(headers: &HeaderMap) -> Option { + headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(':').next()) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +pub fn forwarded_ip(headers: &HeaderMap) -> Option { + ["cf-connecting-ip", "x-real-ip"].iter().find_map(|name| { + headers + .get(*name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| value.parse::().is_ok()) + .map(str::to_string) + }) +} + +pub fn request_is_secure(headers: &HeaderMap) -> bool { + headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("https")) +} + +pub fn admin_cookie(token: &str, max_age: i64, secure: bool) -> String { + format!( + "nodeflare_admin={token}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Strict{}", + if secure { "; Secure" } else { "" } + ) +} diff --git a/backend/src/routes/public.rs b/backend/src/routes/public.rs new file mode 100644 index 0000000..5880778 --- /dev/null +++ b/backend/src/routes/public.rs @@ -0,0 +1,233 @@ +use super::{bearer_or_cookie, cookie, ApiResponse}; +use crate::db::Settings; +use crate::models::{PublicConfig, WakeServersInput}; +use crate::AppState; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; + +#[derive(Serialize)] +pub(crate) struct BootstrapResponse { + config: PublicConfig, + access: &'static str, + servers: Vec, + exchange_rates: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DashboardAccess { + Ok, + Login, + Turnstile, +} + +impl DashboardAccess { + fn name(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Login => "login", + Self::Turnstile => "turnstile", + } + } +} + +pub async fn dashboard_access( + state: &AppState, + headers: &HeaderMap, + settings: &Settings, +) -> Result { + let admin = if let Some(token) = bearer_or_cookie(headers) { + crate::db::session_username(&state.db, &token) + .await + .map_err(ApiResponse::internal)? + .is_some() + } else { + false + }; + if admin { + return Ok(DashboardAccess::Ok); + } + let public = settings.public_config(); + if public.turnstile_enabled { + let proof = cookie(headers, "nodeflare_turnstile").unwrap_or_default(); + if !crate::db::valid_dashboard_proof(&state.db, &proof) + .await + .map_err(ApiResponse::internal)? + { + return Ok(DashboardAccess::Turnstile); + } + } + if !settings.public_dashboard { + return Ok(DashboardAccess::Login); + } + Ok(DashboardAccess::Ok) +} + +pub async fn bootstrap( + State(state): State>, + headers: HeaderMap, +) -> Result, ApiResponse> { + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + let access = dashboard_access(&state, &headers, &settings).await?; + let config = settings.public_config(); + if access != DashboardAccess::Ok { + return Ok(Json(BootstrapResponse { + config, + access: access.name(), + servers: Vec::new(), + exchange_rates: None, + })); + } + let servers = crate::db::queries::list_servers(&state.db, false) + .await + .map_err(ApiResponse::internal)? + .into_iter() + .map(public_server) + .collect(); + let exchange_rates = crate::exchange::current(&state.db) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(BootstrapResponse { + config, + access: access.name(), + servers, + exchange_rates: Some(exchange_rates), + })) +} + +pub async fn config(State(state): State>) -> Result, ApiResponse> { + Ok(Json( + crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)? + .public_config(), + )) +} + +pub async fn servers( + State(state): State>, + headers: HeaderMap, +) -> Result { + require_dashboard(&state, &headers).await?; + let servers = crate::db::queries::list_servers(&state.db, false) + .await + .map_err(ApiResponse::internal)? + .into_iter() + .map(public_server) + .collect::>(); + Ok(Json(serde_json::json!({"servers": servers})).into_response()) +} + +pub async fn history( + State(state): State>, + headers: HeaderMap, + Path(server_id): Path, + Query(query): Query>, +) -> Result { + require_dashboard(&state, &headers).await?; + require_public_server(&state, &server_id).await?; + let hours = query + .get("hours") + .and_then(|value| value.parse().ok()) + .unwrap_or(24); + let points = crate::db::queries::history(&state.db, &server_id, hours) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"points": points})).into_response()) +} + +pub async fn latency_history( + State(state): State>, + headers: HeaderMap, + Path(server_id): Path, + Query(query): Query>, +) -> Result { + require_dashboard(&state, &headers).await?; + require_public_server(&state, &server_id).await?; + let hours = query + .get("hours") + .and_then(|value| value.parse().ok()) + .unwrap_or(24); + let (tasks, points) = crate::db::queries::latency_history(&state.db, &server_id, hours) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"tasks": tasks, "points": points})).into_response()) +} + +pub async fn exchange_rates(State(state): State>) -> Result { + Ok(Json( + crate::exchange::current(&state.db) + .await + .map_err(ApiResponse::internal)?, + ) + .into_response()) +} + +pub async fn wake_servers( + State(state): State>, + headers: HeaderMap, + Json(input): Json, +) -> Result { + require_dashboard(&state, &headers).await?; + if input.server_ids.len() > 500 { + return Err(ApiResponse::bad_request("服务器列表过长")); + } + for server_id in input.server_ids { + state.wake_agent(&server_id).await; + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +async fn require_dashboard(state: &AppState, headers: &HeaderMap) -> Result<(), ApiResponse> { + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + match dashboard_access(state, headers, &settings).await? { + DashboardAccess::Ok => Ok(()), + DashboardAccess::Login => Err(ApiResponse::unauthorized("此仪表盘需要登录后访问")), + DashboardAccess::Turnstile => Err(ApiResponse::forbidden("请先完成人机验证")), + } +} + +async fn require_public_server(state: &AppState, server_id: &str) -> Result<(), ApiResponse> { + if server_id.is_empty() + || server_id.len() > 80 + || server_id.contains('/') + || !crate::db::queries::public_server_exists(&state.db, server_id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("节点不存在")); + } + Ok(()) +} + +fn public_server(server: crate::models::ServerView) -> serde_json::Value { + let mut value = serde_json::to_value(server).unwrap_or_else(|_| serde_json::json!({})); + if let Some(object) = value.as_object_mut() { + for key in [ + "hidden", + "last_ip", + "ip_v4", + "ip_v6", + "network_interface", + "reset_day", + "report_interval", + "collect_interval", + "rx_correction", + "tx_correction", + "agent_mirror", + "offline_notify_disabled", + "auto_update", + ] { + object.remove(key); + } + } + value +} diff --git a/backend/src/routes/remote.rs b/backend/src/routes/remote.rs new file mode 100644 index 0000000..32dba90 --- /dev/null +++ b/backend/src/routes/remote.rs @@ -0,0 +1,89 @@ +use super::ApiResponse; +use crate::middleware::AuthenticatedUser; +use crate::models::CreateRemoteTaskRequest; +use crate::AppState; +use axum::extract::{Extension, Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use std::sync::Arc; + +pub async fn create_task( + State(state): State>, + Extension(user): Extension, + Json(input): Json, +) -> Result { + let command = input.command.trim(); + let script = input.script.trim(); + if input.server_id.is_empty() + || input.server_id.len() > 80 + || command.is_empty() == script.is_empty() + || command.len() > 16_384 + || script.len() > 256 * 1024 + { + return Err(ApiResponse::bad_request( + "请选择一个节点,并且只填写命令或脚本中的一项", + )); + } + if let Some((secret, enabled)) = crate::db::queries::get_totp_secret(&state.db, &user.username) + .await + .map_err(ApiResponse::internal)? + { + if enabled + && (input.totp_code.trim().is_empty() + || !crate::totp::verify_totp(&secret, input.totp_code.trim()) + .map_err(ApiResponse::internal)?) + { + return Err(ApiResponse::unauthorized("远程执行需要有效的两步验证码")); + } + } + if !crate::db::queries::all_server_ids(&state.db) + .await + .map_err(ApiResponse::internal)? + .contains(&input.server_id) + { + return Err(ApiResponse::not_found("节点不存在")); + } + let task = crate::db::queries::create_remote_task( + &state.db, + &input.server_id, + command, + script, + &user.username, + ) + .await + .map_err(ApiResponse::internal)?; + if state.send_remote_task(&task).await { + crate::db::queries::mark_remote_task_sent(&state.db, &task.id, &task.server_id) + .await + .map_err(ApiResponse::internal)?; + } + Ok(( + StatusCode::CREATED, + Json(serde_json::json!({"task_id": task.id})), + ) + .into_response()) +} + +pub async fn get_task( + State(state): State>, + Path(id): Path, +) -> Result { + let task = crate::db::queries::remote_task(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::not_found("任务不存在"))?; + Ok(Json(task).into_response()) +} + +pub async fn get_server_tasks( + State(state): State>, + Path(server_id): Path, +) -> Result { + Ok(Json( + crate::db::queries::server_remote_tasks(&state.db, &server_id, 50) + .await + .map_err(ApiResponse::internal)?, + ) + .into_response()) +} diff --git a/backend/src/routes/site.rs b/backend/src/routes/site.rs new file mode 100644 index 0000000..d6b48e0 --- /dev/null +++ b/backend/src/routes/site.rs @@ -0,0 +1,156 @@ +use crate::routes::ApiResponse; +use crate::AppState; +use axum::body::Body; +use axum::extract::{OriginalUri, State}; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +pub async fn handle(State(state): State>, OriginalUri(uri): OriginalUri) -> Response { + let path = uri.path(); + if path.starts_with("/api/") { + return ApiResponse::not_found("接口不存在").into_response(); + } + if matches!(path, "/admin" | "/admin/" | "/admin/index.html") { + return local_file(&state.config.admin_frontend_dir, "admin.html", false).await; + } + if let Some(relative) = path.strip_prefix("/admin-assets/") { + return local_file(&state.config.admin_frontend_dir, relative, true).await; + } + if let Some(relative) = path.strip_prefix("/agent/") { + return local_file(&state.config.agent_dir, relative, true).await; + } + if let Some(preview) = path.strip_prefix("/__theme-preview/") { + let (token, relative) = preview.split_once('/').unwrap_or((preview, "")); + let base = match crate::db::queries::theme_preview_url(&state.db, token).await { + Ok(Some(base)) => base, + Ok(None) => return (StatusCode::FORBIDDEN, "主题预览链接已失效").into_response(), + Err(error) => return ApiResponse::internal(error).into_response(), + }; + return remote_theme( + &state, + &base, + relative, + &format!("/__theme-preview/{token}"), + ) + .await; + } + if let Some(relative) = path.strip_prefix("/__theme-active/") { + let settings = match crate::db::load_settings(&state.db).await { + Ok(settings) => settings, + Err(error) => return ApiResponse::internal(error).into_response(), + }; + let base = match crate::db::queries::theme_resolved_url( + &state.db, + &settings.active_theme_id, + ) + .await + { + Ok(Some(base)) => base, + _ => return StatusCode::NOT_FOUND.into_response(), + }; + return remote_theme(&state, &base, relative, "/__theme-active").await; + } + + let settings = match crate::db::load_settings(&state.db).await { + Ok(settings) => settings, + Err(error) => return ApiResponse::internal(error).into_response(), + }; + if settings.active_theme_id != crate::theme::BUILTIN_THEME_ID { + if let Ok(Some(base)) = + crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id).await + { + let relative = path.trim_start_matches('/'); + return remote_theme(&state, &base, relative, "/__theme-active").await; + } + } + + let relative = path.trim_start_matches('/'); + if relative.is_empty() || path.starts_with("/instance/") { + return local_file(&state.config.frontend_dir, "index.html", false).await; + } + let response = local_file(&state.config.frontend_dir, relative, true).await; + if response.status() == StatusCode::NOT_FOUND + && !relative.rsplit('/').next().is_some_and(|p| p.contains('.')) + { + local_file(&state.config.frontend_dir, "index.html", false).await + } else { + response + } +} + +async fn remote_theme(state: &AppState, base: &str, relative: &str, prefix: &str) -> Response { + match crate::theme::fetch_theme_path(&state.http, base, relative, prefix).await { + Ok((body, content_type)) => response(body, &content_type, true), + Err(error) => { + tracing::warn!(%error, path = relative, "remote theme asset failed"); + StatusCode::NOT_FOUND.into_response() + } + } +} + +async fn local_file(root: &Path, relative: &str, cache: bool) -> Response { + let Some(path) = safe_join(root, relative) else { + return StatusCode::NOT_FOUND.into_response(); + }; + match tokio::fs::read(&path).await { + Ok(body) => response(body, content_type(&path), cache), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +fn safe_join(root: &Path, relative: &str) -> Option { + let relative = Path::new(relative); + if relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return None; + } + Some(root.join(relative)) +} + +fn response(body: Vec, content_type: &str, cache: bool) -> Response { + let mut response = Response::new(Body::from(body)); + if let Ok(value) = HeaderValue::from_str(content_type) { + response.headers_mut().insert(header::CONTENT_TYPE, value); + } + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(if cache { + "public, max-age=3600" + } else { + "no-cache" + }), + ); + response +} + +fn content_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "ico" => "image/x-icon", + "woff2" => "font/woff2", + "wasm" => "application/wasm", + "sh" => "text/x-shellscript; charset=utf-8", + "ps1" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} diff --git a/backend/src/theme.rs b/backend/src/theme.rs new file mode 100644 index 0000000..fc58cfe --- /dev/null +++ b/backend/src/theme.rs @@ -0,0 +1,290 @@ +use anyhow::{Context, Result}; +use serde_json::Value; +use std::collections::HashSet; +use std::time::Duration; +use url::Url; + +pub const BUILTIN_THEME_ID: &str = "builtin-nodeflare-glass"; +const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024; +const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024; +const SETTINGS_MAX_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone)] +pub struct ResolvedTheme { + pub source_url: String, + pub resolved_url: String, +} + +pub fn resolve_url(value: &str) -> Result { + let source_url = normalize_url(value).context("主题 URL 仅支持 GitHub 仓库地址")?; + let parsed = Url::parse(&source_url)?; + let parts = parsed + .path() + .trim_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + let suffix = if parts.len() > 4 { + format!("/{}", parts[4..].join("/")) + } else { + String::new() + }; + Ok(ResolvedTheme { + source_url, + resolved_url: format!( + "https://raw.githubusercontent.com/{}/{}/{}{}", + parts[0], parts[1], parts[3], suffix + ), + }) +} + +pub fn normalize_url(value: &str) -> Option { + let raw = value.trim(); + if !(12..=2048).contains(&raw.len()) || raw.contains('\\') { + return None; + } + let parsed = Url::parse(raw).ok()?; + if parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || parsed.port().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + let mut parts = parsed + .path() + .trim_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.len() == 2 { + parts.extend(["tree", "main"]); + } + if parts.len() < 4 + || parts[2] != "tree" + || parts.iter().any(|part| { + matches!(*part, "." | "..") + || part.is_empty() + || !part + .chars() + .all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character)) + }) + { + return None; + } + Some(format!("https://github.com/{}", parts.join("/"))) +} + +pub async fn validate_remote(client: &reqwest::Client, base: &str) -> Result<()> { + let (body, _) = fetch_remote(client, base, "index.html", INDEX_MAX_BYTES).await?; + let html = String::from_utf8(body).context("主题 index.html 不是 UTF-8 文本")?; + if html.trim().is_empty() { + anyhow::bail!("主题 index.html 为空"); + } + Ok(()) +} + +pub async fn remote_settings_schema(client: &reqwest::Client, base: &str) -> Result { + let response = client + .get(remote_url(base, "theme.json")?) + .timeout(Duration::from_secs(10)) + .send() + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(serde_json::json!({"schema": 1, "source": "remote", "settings": []})); + } + let response = response.error_for_status()?; + let body = bounded_body(response, SETTINGS_MAX_BYTES).await?; + let value: Value = serde_json::from_slice(&body)?; + validate_settings_schema(value).context("主题 theme.json 设置格式无效") +} + +pub async fn remote_version(client: &reqwest::Client, base: &str) -> Option { + let value = remote_settings_schema(client, base).await.ok()?; + let version = value.get("version")?.as_str()?.trim(); + (!version.is_empty() + && version.len() <= 40 + && version + .chars() + .all(|character| character.is_ascii_alphanumeric() || ".-_+ ".contains(character))) + .then(|| version.to_string()) +} + +pub async fn fetch_theme_path( + client: &reqwest::Client, + base: &str, + relative: &str, + prefix: &str, +) -> Result<(Vec, String)> { + let relative = sanitize_relative(relative)?; + let target = if relative.is_empty() { + "index.html" + } else { + relative + }; + let limit = if target == "index.html" { + INDEX_MAX_BYTES + } else { + ASSET_MAX_BYTES + }; + let (body, content_type) = fetch_remote(client, base, target, limit).await?; + if target == "index.html" { + let html = String::from_utf8(body)?; + return Ok((rewrite_index(&html, prefix).into_bytes(), content_type)); + } + Ok((body, content_type)) +} + +fn sanitize_relative(value: &str) -> Result<&str> { + let relative = value.trim_start_matches('/'); + if relative.len() > 1024 + || relative.contains('\\') + || relative.contains('%') + || relative.contains('?') + || relative.contains('#') + || relative + .split('/') + .any(|part| part == "." || part == ".." || part.is_empty() && !relative.is_empty()) + { + anyhow::bail!("主题资源路径无效"); + } + Ok(relative) +} + +async fn fetch_remote( + client: &reqwest::Client, + base: &str, + relative: &str, + limit: usize, +) -> Result<(Vec, String)> { + let response = client + .get(remote_url(base, relative)?) + .timeout(Duration::from_secs(10)) + .send() + .await? + .error_for_status()?; + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + Ok((bounded_body(response, limit).await?, content_type)) +} + +async fn bounded_body(response: reqwest::Response, limit: usize) -> Result> { + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + anyhow::bail!("远程主题资源过大"); + } + let body = response.bytes().await?; + if body.len() > limit { + anyhow::bail!("远程主题资源过大"); + } + Ok(body.to_vec()) +} + +fn remote_url(base: &str, relative: &str) -> Result { + let base = Url::parse(&format!("{}/", base.trim_end_matches('/')))?; + if base.scheme() != "https" || base.host_str() != Some("raw.githubusercontent.com") { + anyhow::bail!("主题资源地址无效"); + } + let target = base.join(relative)?; + if target.origin() != base.origin() || !target.path().starts_with(base.path()) { + anyhow::bail!("主题资源越界"); + } + Ok(target) +} + +fn rewrite_index(html: &str, prefix: &str) -> String { + let asset_prefix = format!("{}/assets/", prefix.trim_end_matches('/')); + html.replace("\"/assets/", &format!("\"{asset_prefix}")) + .replace("'/assets/", &format!("'{asset_prefix}")) +} + +pub fn builtin_settings_schema() -> Value { + let currencies = [ + "CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD", + ]; + serde_json::json!({ + "schema": 1, + "source": "builtin", + "settings": [ + { + "key": "assetCurrency", "label": "资产折算币种", "type": "select", + "default": "CNY", + "options": currencies.iter().map(|currency| serde_json::json!({ + "label": currency, "value": currency + })).collect::>() + }, + {"key": "enableBlur", "label": "启用毛玻璃效果", "type": "toggle", "default": true}, + {"key": "showOnline", "label": "总览显示在线节点", "type": "toggle", "default": true}, + {"key": "showCarrierLatency", "label": "节点卡片分线路显示延迟", "type": "toggle", "default": false}, + {"key": "telecomLatencyTask", "label": "电信线路任务名称", "type": "text", "default": "", "placeholder": "留空时按任务名称自动匹配"}, + {"key": "mobileLatencyTask", "label": "移动线路任务名称", "type": "text", "default": "", "placeholder": "留空时按任务名称自动匹配"}, + {"key": "unicomLatencyTask", "label": "联通线路任务名称", "type": "text", "default": "", "placeholder": "留空时按任务名称自动匹配"} + ] + }) +} + +fn validate_settings_schema(value: Value) -> Option { + let object = value.as_object()?; + if object.get("schema")?.as_u64()? != 1 { + return None; + } + let fields = object.get("settings")?.as_array()?; + if fields.len() > 40 { + return None; + } + let allowed = [ + "text", "textarea", "url", "color", "select", "toggle", "number", + ]; + let mut keys = HashSet::new(); + for field in fields { + let field = field.as_object()?; + let key = field.get("key")?.as_str()?; + let label = field.get("label")?.as_str()?; + let kind = field.get("type")?.as_str()?; + if key.is_empty() + || key.len() > 64 + || label.is_empty() + || label.chars().count() > 80 + || !allowed.contains(&kind) + || !key + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_-".contains(character)) + || !keys.insert(key) + { + return None; + } + if kind == "select" && !field.get("options").is_some_and(Value::is_array) { + return None; + } + } + let mut result = value; + result + .as_object_mut()? + .insert("source".to_string(), Value::String("remote".to_string())); + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_github_theme_urls() { + assert_eq!( + resolve_url("https://github.com/acme/theme/tree/main/dist") + .unwrap() + .resolved_url, + "https://raw.githubusercontent.com/acme/theme/main/dist" + ); + assert!(resolve_url("https://example.com/theme").is_err()); + } +} diff --git a/backend/src/totp.rs b/backend/src/totp.rs new file mode 100644 index 0000000..029789b --- /dev/null +++ b/backend/src/totp.rs @@ -0,0 +1,89 @@ +use anyhow::{anyhow, Result}; +use rand::Rng; +use totp_lite::{totp_custom, Sha1}; + +const TOTP_DIGITS: u32 = 6; +const TOTP_STEP: u64 = 30; + +/// 生成一个新的 TOTP secret(base32 编码) +pub fn generate_secret() -> String { + let mut rng = rand::thread_rng(); + let secret: [u8; 20] = rng.gen(); + data_encoding::BASE32_NOPAD.encode(&secret) +} + +/// 生成 TOTP URI(用于显示二维码) +pub fn generate_uri(secret: &str, issuer: &str, account_name: &str) -> String { + let issuer = url::form_urlencoded::byte_serialize(issuer.as_bytes()).collect::(); + let account = url::form_urlencoded::byte_serialize(account_name.as_bytes()).collect::(); + format!( + "otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&digits={TOTP_DIGITS}&period={TOTP_STEP}", + ) +} + +/// 验证 TOTP 代码(允许前后一个时间窗口的误差) +pub fn verify_totp(secret: &str, code: &str) -> Result { + let secret_bytes = data_encoding::BASE32_NOPAD + .decode(secret.as_bytes()) + .map_err(|e| anyhow!("Invalid base32 secret: {}", e))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + + // 检查当前时间窗口以及前后各一个窗口(总共 3 个窗口,容忍 ±30 秒) + for offset in [-1i64, 0, 1] { + let time = ((now as i64 + offset * TOTP_STEP as i64) / TOTP_STEP as i64) as u64; + let expected = totp_custom::(TOTP_STEP, TOTP_DIGITS, &secret_bytes, time); + if code == expected { + return Ok(true); + } + } + + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_secret() { + let secret = generate_secret(); + assert!(!secret.is_empty()); + // Base32 字符集 + assert!(secret + .chars() + .all(|c| "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".contains(c))); + } + + #[test] + fn test_generate_uri() { + let secret = "JBSWY3DPEHPK3PXP"; + let uri = generate_uri(secret, "NodeFlare", "admin"); + assert!(uri.starts_with("otpauth://totp/")); + assert!(uri.contains("secret=JBSWY3DPEHPK3PXP")); + assert!(uri.contains("digits=6")); + assert!(uri.contains("period=30")); + } + + #[test] + fn test_verify_totp() { + let secret = "JBSWY3DPEHPK3PXP"; + let secret_bytes = data_encoding::BASE32_NOPAD + .decode(secret.as_bytes()) + .unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let time = now / TOTP_STEP; + + let code = totp_custom::(TOTP_STEP, TOTP_DIGITS, &secret_bytes, time); + assert!(verify_totp(secret, &code).unwrap()); + + // 错误的代码 + assert!(!verify_totp(secret, "000000").unwrap()); + } +} diff --git a/backend/src/turnstile.rs b/backend/src/turnstile.rs new file mode 100644 index 0000000..efe948a --- /dev/null +++ b/backend/src/turnstile.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Serialize)] +struct VerifyRequest<'a> { + secret: &'a str, + response: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + remoteip: Option<&'a str>, +} + +#[derive(Deserialize)] +struct VerifyResponse { + success: bool, + #[serde(default)] + hostname: String, + #[serde(default)] + action: String, +} + +pub async fn verify( + client: &reqwest::Client, + token: &str, + secret: &str, + remote_ip: Option<&str>, + hostname: &str, + expected_action: &str, +) -> bool { + if token.is_empty() || secret.is_empty() { + return false; + } + let result = client + .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") + .timeout(Duration::from_secs(8)) + .form(&VerifyRequest { + secret, + response: token, + remoteip: remote_ip, + }) + .send() + .await + .and_then(reqwest::Response::error_for_status); + let Ok(response) = result else { + return false; + }; + let Ok(result) = response.json::().await else { + return false; + }; + result.success + && (result.hostname.is_empty() || result.hostname.eq_ignore_ascii_case(hostname)) + && (result.action.is_empty() || result.action == expected_action) +} diff --git a/backend/src/websocket/agent.rs b/backend/src/websocket/agent.rs new file mode 100644 index 0000000..9e5bce2 --- /dev/null +++ b/backend/src/websocket/agent.rs @@ -0,0 +1,351 @@ +use super::{AgentCommand, AgentConnection, DashboardEvent}; +use crate::db::queries::{AgentIdentity, PersistResult}; +use crate::models::{AgentReport, RemoteTaskInfo}; +use crate::routes::{forwarded_ip, ApiResponse}; +use crate::AppState; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{ConnectInfo, State}; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::mpsc; + +const MAX_AGENT_MESSAGE_BYTES: usize = 2 * 1024 * 1024; +const MAX_TASK_RESULT_BYTES: usize = 1024 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UpdateMessage { + #[serde(rename = "type")] + _message_type: String, + #[serde(rename = "batchId")] + batch_id: String, + samples: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TaskResultMessage { + #[serde(rename = "type")] + _message_type: String, + task_id: String, + status: String, + result: String, + exit_code: Option, +} + +pub async fn handle( + ws: WebSocketUpgrade, + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, +) -> Response { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or(""); + if token.is_empty() || token.len() > 512 { + return ApiResponse::unauthorized("Agent Token 无效").into_response(); + } + let identity = match crate::db::queries::agent_identity(&state.db, token).await { + Ok(Some(identity)) => identity, + Ok(None) => return ApiResponse::unauthorized("Agent Token 无效").into_response(), + Err(error) => return ApiResponse::internal(error).into_response(), + }; + let remote_ip = forwarded_ip(&headers).unwrap_or_else(|| peer.ip().to_string()); + ws.max_message_size(MAX_AGENT_MESSAGE_BYTES) + .max_frame_size(MAX_AGENT_MESSAGE_BYTES) + .on_upgrade(move |socket| run(socket, state, identity, remote_ip)) +} + +async fn run(socket: WebSocket, state: Arc, identity: AgentIdentity, remote_ip: String) { + let connection_id = uuid::Uuid::new_v4().to_string(); + let (mut websocket_tx, mut websocket_rx) = socket.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + state.agents.write().await.insert( + identity.server_id.clone(), + AgentConnection { + connection_id: connection_id.clone(), + sender: outbound_tx.clone(), + report_interval: identity.report_interval, + collect_interval: identity.collect_interval, + }, + ); + tracing::info!(server_id = %identity.server_id, remote_ip, "Agent connected"); + + if let Some(config) = crate::db::queries::agent_config(&state.db, &identity.server_id) + .await + .ok() + .flatten() + { + let _ = outbound_tx.send(AgentCommand::Text( + serde_json::json!({"type": "config", "ts": crate::db::now(), "config": config}) + .to_string(), + )); + } + if let Ok(tasks) = + crate::db::queries::pending_remote_tasks(&state.db, &identity.server_id).await + { + for task in tasks { + if send_task(&outbound_tx, &task) { + let _ = crate::db::queries::mark_remote_task_sent( + &state.db, + &task.id, + &identity.server_id, + ) + .await; + } + } + } + + let writer = tokio::spawn(async move { + while let Some(command) = outbound_rx.recv().await { + match command { + AgentCommand::Text(text) => { + if websocket_tx.send(Message::Text(text)).await.is_err() { + break; + } + } + AgentCommand::Pong(payload) => { + if websocket_tx.send(Message::Pong(payload)).await.is_err() { + break; + } + } + AgentCommand::Close => { + let _ = websocket_tx.send(Message::Close(None)).await; + break; + } + } + } + }); + + while let Some(message) = websocket_rx.next().await { + match message { + Ok(Message::Text(text)) if text.len() <= MAX_AGENT_MESSAGE_BYTES => { + handle_text(&state, &identity, &remote_ip, &outbound_tx, &text).await; + } + Ok(Message::Ping(payload)) => { + let _ = outbound_tx.send(AgentCommand::Pong(payload)); + } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} + } + } + writer.abort(); + let mut agents = state.agents.write().await; + if agents + .get(&identity.server_id) + .is_some_and(|connection| connection.connection_id == connection_id) + { + agents.remove(&identity.server_id); + } + tracing::info!(server_id = %identity.server_id, "Agent disconnected"); +} + +async fn handle_text( + state: &Arc, + identity: &AgentIdentity, + remote_ip: &str, + outbound: &mpsc::UnboundedSender, + text: &str, +) { + let Ok(value) = serde_json::from_str::(text) else { + return; + }; + match value.get("type").and_then(serde_json::Value::as_str) { + Some("update") => { + let Ok(update) = serde_json::from_value::(value) else { + send_persistence_error(outbound, 0); + return; + }; + if !valid_batch_id(&update.batch_id) { + send_persistence_error(outbound, 0); + return; + } + match crate::db::queries::save_agent_batch( + &state.db, + identity, + &update.batch_id, + &update.samples, + remote_ip, + ) + .await + { + Ok(result) => { + send_ack(outbound, identity, &result); + if !result.reports.is_empty() { + broadcast_reports(state, identity, &result.reports); + let state = Arc::clone(state); + let server_id = identity.server_id.clone(); + tokio::spawn(async move { + let settings = match crate::db::load_settings(&state.db).await { + Ok(settings) => settings, + Err(error) => { + tracing::error!(%error, "failed to load alert settings"); + return; + } + }; + let server_name = crate::db::queries::list_servers(&state.db, true) + .await + .ok() + .and_then(|servers| { + servers + .into_iter() + .find(|server| server.id == server_id) + .map(|server| server.name) + }) + .unwrap_or_else(|| server_id.clone()); + if let Err(error) = crate::notify::evaluate_resource_alerts( + &state.db, + &state.http, + &settings, + &server_id, + &server_name, + ) + .await + { + tracing::error!(%error, server_id, "resource alert evaluation failed"); + } + }); + } + } + Err(error) => { + tracing::error!(%error, server_id = %identity.server_id, "Agent batch persistence failed"); + send_persistence_error(outbound, 0); + } + } + } + Some("task_result") => { + let Ok(result) = serde_json::from_value::(value) else { + return; + }; + if result.task_id.is_empty() + || result.task_id.len() > 80 + || !matches!(result.status.as_str(), "success" | "failed") + || result.result.len() > MAX_TASK_RESULT_BYTES + { + return; + } + if let Err(error) = crate::db::queries::update_remote_task_result( + &state.db, + &identity.server_id, + &result.task_id, + &result.status, + &result.result, + result.exit_code, + ) + .await + { + tracing::error!(%error, server_id = %identity.server_id, "task result persistence failed"); + } + } + _ => {} + } +} + +fn send_task(outbound: &mpsc::UnboundedSender, task: &RemoteTaskInfo) -> bool { + outbound + .send(AgentCommand::Text( + serde_json::json!({ + "type": "remote_task", + "task_id": task.id, + "command": task.command, + "script": task.script, + }) + .to_string(), + )) + .is_ok() +} + +fn send_ack( + outbound: &mpsc::UnboundedSender, + identity: &AgentIdentity, + result: &PersistResult, +) { + let report_interval = identity.report_interval.clamp(15, 3600) * 1000; + let live_interval = identity.collect_interval.clamp(1, 60) * 1000; + let _ = outbound.send(AgentCommand::Text( + serde_json::json!({ + "type": "ack", + "ts": crate::db::now(), + "persisted": true, + "persistenceError": false, + "persistedThroughTs": result.persisted_through, + "nextD1WriteAfterMs": report_interval, + "nextWssReportAfterMs": live_interval, + "realtimeHint": false, + }) + .to_string(), + )); +} + +fn send_persistence_error(outbound: &mpsc::UnboundedSender, persisted: i64) { + let _ = outbound.send(AgentCommand::Text( + serde_json::json!({ + "type": "ack", + "ts": crate::db::now(), + "persisted": false, + "persistenceError": true, + "persistedThroughTs": persisted, + "nextD1WriteAfterMs": 5000, + "nextWssReportAfterMs": 5000, + "realtimeHint": false, + }) + .to_string(), + )); +} + +fn broadcast_reports(state: &AppState, identity: &AgentIdentity, reports: &[AgentReport]) { + if identity.hidden { + return; + } + let samples = reports + .iter() + .map(|report| { + let mut data = serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({})); + if let Some(object) = data.as_object_mut() { + for key in [ + "timestamp", + "cpu_model", + "os", + "kernel", + "arch", + "virtualization", + "gpu_model", + "agent_version", + "ip_v4", + "ip_v6", + ] { + object.remove(key); + } + } + serde_json::json!({"ts": report.timestamp, "data": data}) + }) + .collect::>(); + let payload = serde_json::json!({ + "type": "batchUpdate", + "ts": crate::db::now(), + "updates": [{"serverId": identity.server_id, "samples": samples}], + }) + .to_string(); + let _ = state.dashboard_tx.send(DashboardEvent { + server_id: identity.server_id.clone(), + payload, + }); +} + +fn valid_batch_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +pub fn queue_remote_task(connection: &AgentConnection, task: &RemoteTaskInfo) -> bool { + send_task(&connection.sender, task) +} diff --git a/backend/src/websocket/dashboard.rs b/backend/src/websocket/dashboard.rs new file mode 100644 index 0000000..ed9218c --- /dev/null +++ b/backend/src/websocket/dashboard.rs @@ -0,0 +1,74 @@ +use crate::routes::public::{dashboard_access, DashboardAccess}; +use crate::routes::ApiResponse; +use crate::AppState; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Query, State}; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Deserialize)] +pub struct DashboardQuery { + server_id: Option, +} + +pub async fn handle( + ws: WebSocketUpgrade, + State(state): State>, + Query(query): Query, + headers: HeaderMap, +) -> Response { + let settings = match crate::db::load_settings(&state.db).await { + Ok(settings) => settings, + Err(error) => return ApiResponse::internal(error).into_response(), + }; + match dashboard_access(&state, &headers, &settings).await { + Ok(DashboardAccess::Ok) => {} + Ok(DashboardAccess::Login) => { + return ApiResponse::unauthorized("此仪表盘需要登录后访问").into_response() + } + Ok(DashboardAccess::Turnstile) => { + return ApiResponse::forbidden("请先完成人机验证").into_response() + } + Err(error) => return error.into_response(), + } + let server_id = query + .server_id + .filter(|value| !value.is_empty() && value.len() <= 80 && !value.contains('/')); + ws.on_upgrade(move |socket| run(socket, state, server_id)) +} + +async fn run(socket: WebSocket, state: Arc, server_id: Option) { + let (mut sender, mut receiver) = socket.split(); + let mut updates = state.dashboard_tx.subscribe(); + loop { + tokio::select! { + update = updates.recv() => match update { + Ok(update) if server_id.as_ref().is_none_or(|id| id == &update.server_id) => { + if sender.send(Message::Text(update.payload)).await.is_err() { + break; + } + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }, + incoming = receiver.next() => match incoming { + Some(Ok(Message::Text(text))) if text == "ping" => { + if sender.send(Message::Text("pong".to_string())).await.is_err() { + break; + } + } + Some(Ok(Message::Ping(payload))) => { + if sender.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + _ => {} + } + } + } +} diff --git a/backend/src/websocket/mod.rs b/backend/src/websocket/mod.rs new file mode 100644 index 0000000..106d4d9 --- /dev/null +++ b/backend/src/websocket/mod.rs @@ -0,0 +1,25 @@ +pub mod agent; +pub mod dashboard; + +use tokio::sync::mpsc; + +#[derive(Clone)] +pub struct AgentConnection { + pub connection_id: String, + pub sender: mpsc::UnboundedSender, + pub report_interval: i64, + pub collect_interval: i64, +} + +#[derive(Debug)] +pub enum AgentCommand { + Text(String), + Pong(Vec), + Close, +} + +#[derive(Debug, Clone)] +pub struct DashboardEvent { + pub server_id: String, + pub payload: String, +} diff --git a/build.rs b/build.rs deleted file mode 100644 index 44e5e57..0000000 --- a/build.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::{env, fs, path::PathBuf}; - -fn main() { - let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory")); - let dist = root.join("frontend/admin-dist"); - let out = PathBuf::from(env::var("OUT_DIR").expect("build output directory")); - let release = env::var("PROFILE").as_deref() == Ok("release"); - - println!("cargo:rerun-if-changed=frontend/admin-dist/admin.html"); - println!("cargo:rerun-if-changed=frontend/admin-dist/admin.js"); - println!("cargo:rerun-if-changed=frontend/admin-dist/admin.css"); - println!("cargo:rerun-if-env-changed=NODEFLARE_VERSION"); - - let files = [ - ( - "admin.html", - b"Admin unavailable

Run the frontend admin build first.

".as_slice(), - ), - ( - "admin.js", - b"console.error('Run the frontend admin build first.');".as_slice(), - ), - ( - "admin.css", - b"body{font-family:system-ui,sans-serif}".as_slice(), - ), - ]; - - for (name, fallback) in files { - let source = dist.join(name); - let body = match fs::read(&source) { - Ok(body) => body, - Err(error) if release => panic!( - "missing embedded admin asset {} ({error}); run `bun run build:frontend` before the release Worker build", - source.display() - ), - Err(_) => fallback.to_vec(), - }; - fs::write(out.join(name), body).expect("write embedded admin asset"); - } -} diff --git a/bun.lock b/bun.lock index 9bc4b6b..feb2624 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "nodeflare", "devDependencies": { - "wrangler": "^4.127.0", "ws": "^8.21.3", }, }, @@ -32,144 +31,8 @@ "nanoid": "3.3.18", }, "packages": { - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], - - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260826.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8UsGGY8ZUiYHOWdsxBlNsGmaHBGArVwJ3CM4nWpfBhthjjYe4M/OqrTpqKF7NNWb63qQiv1d8Z+Z6/hmUqNKIQ=="], - - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260826.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0bLqVQYsQ3v3FdYGmzh23vi9fJeYTBx19o4LUySIsRcgBggGSlR39ml162vTXvZzUISOjVW2qfL7Y+SMrrfXmQ=="], - - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260826.1", "", { "os": "linux", "cpu": "x64" }, "sha512-DTC0yWzybX4gUH5Q1pJo3UwEQjp0Gmz0Q71I+39xT9SXBesX5QndOIQv45yHQ86Z84EzK2WwaENdlpmDSvJKmw=="], - - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260826.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFerWi+DP2Ckc6eATAS4dhotBt8IeXJjTzIgy18H+uqsswlXc7A8HsnAunHL9v/7/BjCgq46iMo2g4iUAsEpDw=="], - - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260826.1", "", { "os": "win32", "cpu": "x64" }, "sha512-X26hulrG2MSSfpRmjZbCq98LNaZnrRqbmGcgo7g1U/U0nJ5npYruPm3fAyZ2y6VDr5CmQo9BLCahxP8VB/QR6A=="], - - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], - - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], - - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], - "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], - - "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], - - "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], @@ -204,10 +67,6 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - - "@speed-highlight/core": ["@speed-highlight/core@1.2.23", "", {}, "sha512-iRoq6i6JDJP6Mt2A5JaPvzw0pgYHH6k92ij+yXiTrB7T2y9N789aWE3EHWj/5ztlJBokcCBja3iYLVdu5wgnkg=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -282,14 +141,10 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="], - "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -318,12 +173,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -334,8 +185,6 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -362,16 +211,10 @@ "lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="], - "miniflare": ["miniflare@5.20260826.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260826.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-ZXR3Bieg+B5MK0T/zYIWaZiCGCb9Z3en4+/TleYKhIGPLx/e0cRcG/ZvvTviUapVBBK2zODB+aiypdIojU3x6Q=="], - "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "nodeflare-ui": ["nodeflare-ui@workspace:frontend"], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -398,44 +241,22 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], - "workerd": ["workerd@1.20260826.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260826.1", "@cloudflare/workerd-darwin-arm64": "1.20260826.1", "@cloudflare/workerd-linux-64": "1.20260826.1", "@cloudflare/workerd-linux-arm64": "1.20260826.1", "@cloudflare/workerd-windows-64": "1.20260826.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oTG9ot5zxO9OjjKCskt86+nVrBL0kiUqExHnYaTg4OCkHf/K4zr+9hYvgwmG8DdCWG0TQGErHzD+1h50TM5b+w=="], - - "wrangler": ["wrangler@4.127.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260826.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260826.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260826.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-4dPqcBEMJfGeZeNnjHT7ThNJs+EiNYxUTg4ywqIdQubcXHBhFeVMQyHV4A9AOhZRFr83cckqdds034KGcr/dtw=="], - "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], - - "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - - "miniflare/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], } } diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..36b9463 --- /dev/null +++ b/dev.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$root_dir" + +if [ ! -f backend/config.toml ]; then + echo "配置文件不存在,从示例复制..." + cp backend/config.example.toml backend/config.toml + echo "请编辑 backend/config.toml(尤其是 admin_password)后重新运行" + exit 1 +fi + +echo "构建前端..." +sh scripts/build-frontend.sh + +echo "启动后端(开发模式)..." +exec cargo run --locked --manifest-path backend/Cargo.toml -- --config backend/config.toml diff --git a/frontend/public/_headers b/frontend/public/_headers deleted file mode 100644 index b367a23..0000000 --- a/frontend/public/_headers +++ /dev/null @@ -1,6 +0,0 @@ -/* - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - Referrer-Policy: strict-origin-when-cross-origin - Permissions-Policy: camera=(), microphone=(), geolocation=() - Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ws: wss:; frame-src https://challenges.cloudflare.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none' diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ba82da..f1d6b53 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { NodeCard } from "./components/NodeCard"; import { SiteLogo } from "./components/SiteLogo"; import { StatsBar } from "./components/StatsBar"; import { TurnstileWidget } from "./components/TurnstileWidget"; +import { ApiError } from "./api"; import { ui } from "./locale"; const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails }))); @@ -15,6 +16,8 @@ function LoginGate() { const [password, setPassword] = useState(""); const [turnstileToken, setTurnstileToken] = useState(""); const [turnstileReset, setTurnstileReset] = useState(0); + const [totpCode, setTotpCode] = useState(""); + const [totpRequired, setTotpRequired] = useState(false); const [busy, setBusy] = useState(false); const locale = config.locale; @@ -24,10 +27,13 @@ function LoginGate() { setBusy(true); setError(""); try { - await login(username, password, turnstileToken); + await login(username, password, turnstileToken, totpCode); setPassword(""); setTurnstileToken(""); + setTotpCode(""); + setTotpRequired(false); } catch (reason) { + if (reason instanceof ApiError && reason.status === 428) setTotpRequired(true); setError(reason instanceof Error ? reason.message : ui(locale, "登录失败", "Unable to sign in")); setTurnstileToken(""); setTurnstileReset((value) => value + 1); @@ -46,6 +52,7 @@ function LoginGate() {
void submit(event)}> + {totpRequired ? : null} {config.turnstile_login_enabled ?
: null} {error ?

{error}

: null} diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx index c614f65..759c979 100644 --- a/frontend/src/AppProvider.tsx +++ b/frontend/src/AppProvider.tsx @@ -50,7 +50,7 @@ export interface AppState { openServer: (server: Server) => void; goHome: () => void; reload: () => Promise; - login: (username: string, password: string, turnstileToken: string) => Promise; + login: (username: string, password: string, turnstileToken: string, totpCode: string) => Promise; verify: (token: string) => Promise; } @@ -87,6 +87,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const liveConnectedRef = useRef(false); const playbackRef = useRef(new Map()); const serversRef = useRef([]); + const themeSwitchFrame = useRef(null); const dark = appearance ? appearance === "dark" : config.default_theme === "system" ? systemDark : config.default_theme === "dark"; const background = resolveBackground(config.background_url, dark); @@ -132,9 +133,9 @@ export function AppProvider({ children }: { children: ReactNode }) { } }, []); - const login = useCallback(async (username: string, password: string, turnstileToken: string) => { + const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => { const derived = await derivePassword(password, config.password_client_salt); - const result = await api.login(username.trim(), password, derived, turnstileToken); + const result = await api.login(username.trim(), password, derived, turnstileToken, totpCode); setToken(result.token); setAccess("ok"); await reload(); @@ -259,6 +260,24 @@ export function AppProvider({ children }: { children: ReactNode }) { setAppearance((current) => { const resolved = current ?? (dark ? "dark" : "light"); const next = resolved === "dark" ? "light" : "dark"; + + // Komari 优化:标记主题切换中,避免过渡动画闪烁 + const root = document.documentElement; + root.dataset.themeSwitching = "true"; + root.classList.toggle("dark", next === "dark"); + root.style.colorScheme = next; + + // 双帧延迟后移除标记 + if (themeSwitchFrame.current !== null) { + cancelAnimationFrame(themeSwitchFrame.current); + } + themeSwitchFrame.current = requestAnimationFrame(() => { + themeSwitchFrame.current = requestAnimationFrame(() => { + delete root.dataset.themeSwitching; + themeSwitchFrame.current = null; + }); + }); + localStorage.setItem("nodeflare-theme", next); return next; }); @@ -287,5 +306,14 @@ export function AppProvider({ children }: { children: ReactNode }) { verify, }), [access, background, blur, carrierLatency, config, configReady, dark, error, exchangeRates, goHome, liveMetrics, liveServers, loading, login, openServer, reload, selectedId, toggleTheme, verify]); + // 组件卸载时清理动画帧 + useEffect(() => { + return () => { + if (themeSwitchFrame.current !== null) { + cancelAnimationFrame(themeSwitchFrame.current); + } + }; + }, []); + return {children}; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2e3e1ce..c1d8d56 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema } from "./types"; +import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, RemoteTask, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types"; const TOKEN_KEY = "nodeflare-admin-token"; export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized"; @@ -54,13 +54,23 @@ export const api = { request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`), verifyTurnstile: (token: string) => request("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }), - login: (username: string, password: string, passwordDerived: string, turnstileToken: string) => + login: (username: string, password: string, passwordDerived: string, turnstileToken: string, totpCode = "") => request<{ token: string }>("/api/admin/login", { method: "POST", - body: JSON.stringify({ username, password, password_derived: passwordDerived, turnstile_token: turnstileToken }), + body: JSON.stringify({ username, password, password_derived: passwordDerived, turnstile_token: turnstileToken, totp_code: totpCode }), }), logout: () => request("/api/admin/logout", { method: "POST" }), settings: () => request("/api/admin/settings", {}, true), + twoFactorStatus: () => request("/api/admin/2fa/status", {}, true), + setupTwoFactor: () => request("/api/admin/2fa/setup", { method: "POST" }, true), + enableTwoFactor: (totpCode: string) => request("/api/admin/2fa/enable", { + method: "POST", + body: JSON.stringify({ totp_code: totpCode }), + }, true), + disableTwoFactor: (totpCode: string) => request("/api/admin/2fa/disable", { + method: "POST", + body: JSON.stringify({ totp_code: totpCode }), + }, true), latencyTasks: () => request<{ tasks: LatencyTask[] }>("/api/admin/latency-tasks", {}, true), createLatencyTask: (input: LatencyTaskInput) => request<{ id: string }>("/api/admin/latency-tasks", { method: "POST", body: JSON.stringify(input) }, true), @@ -123,6 +133,11 @@ export const api = { true, ), databaseStats: () => request("/api/admin/database", {}, true), - cloudflareUsage: () => request("/api/admin/cloudflare-usage", {}, true), clearHistory: () => request("/api/admin/history", { method: "DELETE" }, true), + createRemoteTask: (input: RemoteTaskInput) => request<{ task_id: string }>("/api/admin/remote/task", { + method: "POST", + body: JSON.stringify(input), + }, true), + remoteTask: (id: string) => request(`/api/admin/remote/task/${encodeURIComponent(id)}`, {}, true), + remoteTasks: (serverId: string) => request(`/api/admin/remote/tasks/${encodeURIComponent(serverId)}`, {}, true), }; diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 0436360..b9b6838 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -5,7 +5,6 @@ import { ChevronDown, ChevronUp, CircleAlert, - Cloud, CircleCheck, Coins, Copy, @@ -26,6 +25,7 @@ import { ShieldCheck, SlidersHorizontal, Sun, + Terminal, Trash2, Check, ExternalLink, @@ -34,7 +34,7 @@ import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, use import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api"; import { formatByteSize, isOnline, parseByteSize } from "../format"; import { derivePassword } from "../password"; -import { ASSET_CURRENCIES, type AdminServer, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types"; +import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseStats, type ExchangeRates, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types"; import { Checkbox } from "./Checkbox"; import { TurnstileWidget } from "./TurnstileWidget"; import { useDialog } from "./useDialog"; @@ -47,7 +47,7 @@ import pkg from "../../package.json"; const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version; -type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "about"; +type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "remote" | "about"; type AgentPlatform = "linux" | "windows" | "macos" | "freebsd"; interface AgentInstallInfo { @@ -63,7 +63,8 @@ const adminPages: Record = { themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" }, alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" }, security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" }, - data: { title: "监控数据库", description: "查看 D1 用量、汇率状态并维护历史数据" }, + data: { title: "监控数据库", description: "查看数据库统计、汇率状态并维护历史数据" }, + remote: { title: "远程执行", description: "向在线节点发送命令或脚本并查看执行结果" }, about: { title: "关于", description: "版本信息与项目地址" }, }; @@ -180,6 +181,8 @@ export function AdminPanel({ const [password, setPassword] = useState(""); const [turnstileToken, setTurnstileToken] = useState(""); const [turnstileReset, setTurnstileReset] = useState(0); + const [loginTotpCode, setLoginTotpCode] = useState(""); + const [loginTotpRequired, setLoginTotpRequired] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); @@ -189,7 +192,6 @@ export function AdminPanel({ const [draggingId, setDraggingId] = useState(""); const [settings, setSettings] = useState(null); const [database, setDatabase] = useState(null); - const [cloudflareUsage, setCloudflareUsage] = useState(null); const [exchangeRates, setExchangeRates] = useState(null); const [themes, setThemes] = useState([]); const [themeName, setThemeName] = useState(""); @@ -208,16 +210,32 @@ export function AdminPanel({ const [rxBaseBytes, setRxBaseBytes] = useState(0); const [txBaseBytes, setTxBaseBytes] = useState(0); const [installPlatform, setInstallPlatform] = useState("linux"); + const [selectedServerId, setSelectedServerId] = useState(""); + const [remoteCommand, setRemoteCommand] = useState(""); + const [remoteScript, setRemoteScript] = useState(""); + const [remoteMode, setRemoteMode] = useState<"command" | "script">("command"); + const [remoteTasks, setRemoteTasks] = useState([]); + const [remoteTotpCode, setRemoteTotpCode] = useState(""); + const [twoFactorStatus, setTwoFactorStatus] = useState(null); + const [twoFactorSetup, setTwoFactorSetup] = useState(null); + const [twoFactorCode, setTwoFactorCode] = useState(""); + const load = useCallback(async () => { if (!getToken()) return; setBusy(true); setError(""); try { - const [serverResult, settingsResult, themesResult] = await Promise.all([api.adminServers(), api.settings(), api.themes()]); + const [serverResult, settingsResult, themesResult, twoFactorResult] = await Promise.all([ + api.adminServers(), + api.settings(), + api.themes(), + api.twoFactorStatus(), + ]); setServers(serverResult.servers); setThemes(themesResult.themes); setSettings(settingsResult); + setTwoFactorStatus(twoFactorResult); setAuthenticated(true); } catch (reason) { if (reason instanceof ApiError && reason.status === 401) { @@ -262,13 +280,16 @@ export function AdminPanel({ setError(""); try { const passwordDerived = await derivePassword(password, config.password_client_salt); - const result = await api.login(username.trim(), password, passwordDerived, turnstileToken); + const result = await api.login(username.trim(), password, passwordDerived, turnstileToken, loginTotpCode); setToken(result.token); setPassword(""); setTurnstileToken(""); + setLoginTotpCode(""); + setLoginTotpRequired(false); setAuthenticated(true); await load(); } catch (reason) { + if (reason instanceof ApiError && reason.status === 428) setLoginTotpRequired(true); setError(reason instanceof Error ? reason.message : "登录失败"); setTurnstileToken(""); setTurnstileReset((value) => value + 1); @@ -465,13 +486,6 @@ export function AdminPanel({ finally { setBusy(false); } } - async function loadCloudflareUsage() { - setBusy(true); setError(""); - try { setCloudflareUsage(await api.cloudflareUsage()); } - catch (reason) { setError(reason instanceof Error ? reason.message : "读取 Cloudflare 用量失败"); } - finally { setBusy(false); } - } - async function loadThemeSettings() { setBusy(true); setError(""); try { setThemeSettingsSchema(await api.themeSettings()); } @@ -544,6 +558,53 @@ export function AdminPanel({ try { await api.logout(); } finally { setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]); + setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode(""); + } + } + + async function setupTwoFactor() { + setBusy(true); setError(""); setNotice(""); + try { + const setup = await api.setupTwoFactor(); + setTwoFactorSetup(setup); + setTwoFactorStatus({ enabled: false, has_secret: true }); + setTwoFactorCode(""); + setNotice("两步验证密钥已生成,请先加入验证器再启用"); + } catch (reason) { setError(reason instanceof Error ? reason.message : "生成两步验证密钥失败"); } + finally { setBusy(false); } + } + + async function enableTwoFactor() { + if (!/^\d{6}$/.test(twoFactorCode)) { setError("请输入 6 位两步验证码"); return; } + setBusy(true); setError(""); setNotice(""); + try { + await api.enableTwoFactor(twoFactorCode); + setTwoFactorStatus({ enabled: true, has_secret: true }); + setTwoFactorSetup(null); + setTwoFactorCode(""); + setNotice("两步验证已启用"); + } catch (reason) { setError(reason instanceof Error ? reason.message : "启用两步验证失败"); } + finally { setBusy(false); } + } + + async function disableTwoFactor() { + if (!/^\d{6}$/.test(twoFactorCode)) { setError("请输入 6 位两步验证码"); return; } + setBusy(true); setError(""); setNotice(""); + try { + await api.disableTwoFactor(twoFactorCode); + setTwoFactorStatus({ enabled: false, has_secret: true }); + setTwoFactorCode(""); + setNotice("两步验证已禁用"); + } catch (reason) { setError(reason instanceof Error ? reason.message : "禁用两步验证失败"); } + finally { setBusy(false); } + } + + async function copyTwoFactorValue(value: string) { + try { + await navigator.clipboard.writeText(value); + setNotice("已复制"); + } catch { + setError("复制失败,请手动选择复制"); } } @@ -583,6 +644,55 @@ export function AdminPanel({ }; const siteLogoUrl = settings ? settings.logo_url : config.logo_url; + const createRemoteTask = async () => { + if (!selectedServerId) { + setError("请选择服务器"); + return; + } + if (remoteMode === "command" && !remoteCommand.trim()) { + setError("请输入命令"); + return; + } + if (remoteMode === "script" && !remoteScript.trim()) { + setError("请输入脚本"); + return; + } + if (twoFactorStatus?.enabled && !/^\d{6}$/.test(remoteTotpCode)) { + setError("远程执行需要 6 位两步验证码"); + return; + } + + setBusy(true); + setError(""); + try { + const data = await api.createRemoteTask({ + server_id: selectedServerId, + command: remoteMode === "command" ? remoteCommand : "", + script: remoteMode === "script" ? remoteScript : "", + totp_code: remoteTotpCode, + }); + setNotice(`任务已创建: ${data.task_id}`); + setRemoteCommand(""); + setRemoteScript(""); + setRemoteTotpCode(""); + void loadRemoteTasks(selectedServerId); + } catch (err) { + setError(err instanceof Error ? err.message : "创建任务失败"); + } finally { + setBusy(false); + } + }; + + const loadRemoteTasks = async (serverId = selectedServerId) => { + if (!serverId) return; + + try { + setRemoteTasks(await api.remoteTasks(serverId)); + } catch (err) { + setError(err instanceof Error ? err.message : "加载任务列表失败"); + } + }; + return (
{/* toast 的定位是按后台的顶栏算的;登录页没有顶栏,那里的错误走表单内提示 */} @@ -595,9 +705,10 @@ export function AdminPanel({

管理员登录

{config.site_name}

- {config.turnstile_login_enabled || config.turnstile_enabled ?
: null} + {loginTotpRequired ? : null} + {config.turnstile_login_enabled ?
: null} {error ? : null} - +
:
@@ -629,6 +740,7 @@ export function AdminPanel({ + @@ -711,33 +823,112 @@ export function AdminPanel({ {tab === "security" ? <>
账号与 Cloudflare 防护
+
+
TOTP 两步验证

登录与远程执行可使用验证器生成的 6 位动态验证码保护。

{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}
+ {twoFactorSetup ?
+ + +

将密钥或 URI 加入 Google Authenticator、Aegis、2FAS 等验证器,再输入当前验证码确认。

+
: null} + {twoFactorStatus?.enabled ?
:
+ {twoFactorSetup ? :

{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}

} + + {twoFactorSetup ? : null} +
} +
updateSettings("turnstile_enabled", value)} /> updateSettings("turnstile_login_enabled", value)} />
-

留空则读取后台配置。

+

已配置的密钥显示为 ********;两项同时清空可禁用 Turnstile。

: null} {tab === "data" ? <> -
D1 数据维护
+
数据库维护
{database ? <> :

正在读取数据库统计...

}
-
每日汇率

{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取 D1 汇率快照"}

+
每日汇率

{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取汇率快照"}

{exchangeRates ?
{ASSET_CURRENCIES.filter((currency) => currency !== "CNY").map((currency) => )}
币种1 CNY 可兑换
{currency}{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}
:
尚未读取
}
-
-
Cloudflare 用量

统计周期使用 UTC

-
-

Token 需要账户级 Account Analytics: Read 权限,并授权对应账户;留空读取 Worker Secret。

- {cloudflareUsage ? <>
周期D1 读取D1 写入Workers 请求DO 请求(估算)DO 时长 (GB-s)
:
尚未读取
} -

清空历史不会删除节点、密钥或最新状态。

: null} {tab !== "data" ?
: null} + ) : tab === "remote" ? ( +
+

创建远程任务

{selectedServerId ? : null}
+
+ +
+
+ + +
+ {remoteMode === "command" ? ( + + ) : ( +