Fixes and improvements

This commit is contained in:
imengying committed 2026-08-11 22:50:34 +08:00
1 parent 1c9540c13c
commit f24efd1401
33 files changed
+2618 -1093

No files matched your search

+39 -11
View File
@@ -12,16 +12,19 @@ on:
default: v0.0.1
permissions:
contents: write
contents: read
jobs:
metadata:
name: Validate tag
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: version
name: Validate three-part tag
shell: bash
@@ -40,6 +43,11 @@ jobs:
name: Agent (${{ matrix.platform }})
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
attestations: write
contents: read
id-token: write
env:
CARGO_TARGET_DIR: target/agent
NODEFLARE_AGENT_VERSION: ${{ needs.metadata.outputs.version }}
@@ -72,8 +80,10 @@ jobs:
extension: ""
cross: false
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: ${{ matrix.target }}
- name: Install Linux cross compiler
@@ -90,7 +100,10 @@ jobs:
run: |
mkdir -p release
cp "target/agent/${{ matrix.target }}/release/nodeflare-agent${{ matrix.extension }}" "release/${{ matrix.asset }}"
- uses: actions/upload-artifact@v6
- 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 }}
@@ -100,9 +113,16 @@ jobs:
name: Frontend
needs: metadata
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
attestations: write
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2.2.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- name: Install dependencies
@@ -111,7 +131,10 @@ jobs:
run: bun run --cwd frontend build:public
- name: Package frontend
run: tar -czf "nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz" -C frontend/dist .
- uses: actions/upload-artifact@v6
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend
path: nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz
@@ -122,10 +145,15 @@ jobs:
if: github.event_name == 'push'
needs: [metadata, agent, frontend]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: read
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Build changelog from commits
shell: bash
run: |
@@ -141,7 +169,7 @@ jobs:
git log -1 --pretty=format:'- %s' "$GITHUB_SHA" > CHANGELOG.md
fi
printf '\n' >> CHANGELOG.md
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: release-assets
merge-multiple: true
@@ -154,7 +182,7 @@ jobs:
cp agent/install.ps1 release-package/install-agent.ps1
cp agent/install-macos.sh release-package/install-agent-macos.sh
(cd release-package && sha256sum * > SHA256SUMS)
- uses: softprops/action-gh-release@v3
- uses: softprops/action-gh-release@fe965f7af51af5f2602596916f38a38df2e33de0 # v3.0.2
with:
body_path: CHANGELOG.md
files: release-package/**
Generated
+77 -57
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "async-trait"
version = "0.1.91"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@@ -21,17 +21,17 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "block-buffer"
version = "0.10.4"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"generic-array",
"hybrid-array",
]
[[package]]
@@ -64,33 +64,54 @@ dependencies = [
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
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.1.7"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"generic-array",
"typenum",
"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.10.7"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
"ctutils",
]
[[package]]
@@ -173,16 +194,6 @@ dependencies = [
"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 = "heck"
version = "0.5.0"
@@ -191,9 +202,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
@@ -218,6 +229,15 @@ dependencies = [
"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.2.0"
@@ -329,9 +349,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.103"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
@@ -367,7 +387,9 @@ name = "nodeflare-worker"
version = "0.0.1"
dependencies = [
"base64",
"futures-util",
"hmac",
"pbkdf2",
"serde",
"serde_json",
"sha2",
@@ -389,6 +411,16 @@ 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"
@@ -528,9 +560,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.10.9"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -576,12 +608,6 @@ dependencies = [
"syn 2.0.119",
]
[[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"
@@ -664,17 +690,11 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
@@ -685,9 +705,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
version = "0.4.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -695,9 +715,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -705,9 +725,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -718,9 +738,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
@@ -740,9 +760,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.103"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
dependencies = [
"js-sys",
"wasm-bindgen",
+5 -3
View File
@@ -10,11 +10,13 @@ repository = "https://github.com/imengying/NodeFlare"
crate-type = ["cdylib"]
[dependencies]
base64 = "0.22"
hmac = "0.12"
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.10"
sha2 = "0.11"
worker = { version = "0.8", features = ["d1"] }
[profile.release]
+10 -1
View File
@@ -13,7 +13,8 @@
- 用户名密码登录、Cloudflare Turnstile、节点隐藏和公开备注
- 节点管理、批量删除、拖拽排序、Agent 密钥轮换和在线配置
- Telegram 通知、资源告警、离线/到期提醒和数据维护
- 内置 Komari Glass 主题,支持展示项、背景图和主题选项配置
- 内置 Komari Glass 主题,并提供远程主题商店
- 远程主题仅支持 GitHub `tree` 地址,Worker 代理 `index.html` 与 `assets/`
- Rust Agent 支持 Linux x86_64/ARM64、Windows x86_64 和 macOS ARM64,可按节点自动更新
## 部署到 Cloudflare
@@ -56,6 +57,10 @@
| `POST` | `/api/admin/servers/:id/token` | 轮换探针密钥 |
| `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 节点、历史和数据库统计 |
@@ -64,6 +69,10 @@
| `DELETE` | `/api/admin/history` | 清理历史指标 |
| `POST` | `/api/admin/notifications/test` | 发送一条通知测试消息 |
## 主题
GitHub `tree` 地址对应的目录需提供 `index.html` 和 `assets/`,并使用 NodeFlare 公开 API 读取数据。启用第三方主题前请确认来源可信。
## 鸣谢
感谢 [CF-Server-Monitor](https://github.com/huilang-me/CF-Server-Monitor) 项目提供的思路。
+180 -237
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "base64"
version = "0.22.1"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
@@ -16,34 +16,29 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "block-buffer"
version = "0.10.4"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"generic-array",
"hybrid-array",
]
[[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.0"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "nodeflare-agent"
version = "0.0.1"
dependencies = [
"serde",
"serde_json",
"sha2",
"sysinfo",
"ureq",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -51,47 +46,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "core-foundation"
version = "0.10.1"
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"core-foundation-sys",
"libc",
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"generic-array",
"typenum",
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.10.7"
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[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",
]
@@ -107,41 +127,25 @@ dependencies = [
]
[[package]]
name = "errno"
version = "0.3.14"
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"libc",
"windows-sys 0.61.2",
"litrs",
]
[[package]]
name = "fastrand"
version = "2.5.0"
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
[[package]]
name = "form_urlencoded"
@@ -152,16 +156,6 @@ dependencies = [
"percent-encoding",
]
[[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"
@@ -174,14 +168,34 @@ dependencies = [
]
[[package]]
name = "getrandom"
version = "0.4.3"
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
@@ -287,6 +301,16 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -299,18 +323,18 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "log"
version = "0.4.33"
@@ -324,20 +348,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
name = "nodeflare-agent"
version = "0.0.1"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
"serde",
"serde_json",
"sha2",
"sysinfo",
"ureq",
"url",
]
[[package]]
@@ -349,6 +368,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -374,61 +399,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -438,6 +414,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -456,12 +438,6 @@ 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 = "ring"
version = "0.17.14"
@@ -470,23 +446,10 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"getrandom",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -524,38 +487,6 @@ dependencies = [
"untrusted",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "serde"
version = "1.0.229"
@@ -601,9 +532,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.10.9"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -682,16 +613,33 @@ dependencies = [
]
[[package]]
name = "tempfile"
version = "3.27.0"
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"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]]
@@ -724,20 +672,33 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
dependencies = [
"base64",
"cookie_store",
"log",
"native-tls",
"once_cell",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
@@ -752,18 +713,18 @@ dependencies = [
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[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"
@@ -776,15 +737,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
@@ -926,15 +878,6 @@ dependencies = [
"windows-targets",
]
[[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.52.6"
+3 -5
View File
@@ -9,14 +9,12 @@ repository = "https://github.com/imengying/NodeFlare"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
[target.'cfg(target_os = "linux")'.dependencies]
ureq = { version = "2.12", default-features = false, features = ["json", "tls"] }
sha2 = "0.11"
ureq = { version = "3.4", default-features = false, features = ["json", "rustls"] }
url = "2.5"
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
sysinfo = { version = "0.39.6", default-features = false, features = ["disk", "network", "system"] }
ureq = { version = "2.12", default-features = false, features = ["json", "native-tls"] }
[profile.release]
codegen-units = 1
+5 -1
View File
@@ -38,7 +38,11 @@ install_agent() {
collect_interval=$(arg --collect-interval "$@" || printf '5')
worker_url=${worker_url%/}
safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid install argument" >&2; exit 1; }
case "$worker_url" in http://?*|https://?*) ;; *) echo "Worker URL must use HTTP or HTTPS" >&2; exit 1 ;; esac
case "$worker_url" in
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
*) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;;
esac
case "$worker_url" in *@*) echo "Worker URL must not contain user information" >&2; exit 1 ;; esac
case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; }
case "$collect_interval" in ''|*[!0-9]*) echo "Invalid collect interval" >&2; exit 1 ;; esac
+5 -1
View File
@@ -46,7 +46,11 @@ interval=$(arg --interval "$@" || printf '60')
collect_interval=$(arg --collect-interval "$@" || printf '5')
worker_url=${worker_url%/}
safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid argument" >&2; exit 1; }
case "$worker_url" in http://?*|https://?*) ;; *) echo "Worker URL must use HTTP or HTTPS" >&2; exit 1 ;; esac
case "$worker_url" in
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
*) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;;
esac
case "$worker_url" in *@*) echo "Worker URL must not contain user information" >&2; exit 1 ;; esac
case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; }
case "$collect_interval" in ''|*[!0-9]*) echo "Invalid collect interval" >&2; exit 1 ;; esac
+8 -3
View File
@@ -21,17 +21,22 @@ function Assert-Safe([string]$Name, [string]$Value) {
function Assert-WorkerUrl([string]$Value) {
$Parsed = $null
$SecureScheme = $false
if ([Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$Parsed)) {
$SecureScheme = $Parsed.Scheme -eq "https" -or (
$Parsed.Scheme -eq "http" -and $Parsed.Host -in @("localhost", "127.0.0.1", "::1")
)
}
if (
$Value.Length -gt 2048 -or
$Value -match "\s" -or
$Value.Contains("'") -or
-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$Parsed) -or
$Parsed.Scheme -notin @("http", "https") -or
-not $SecureScheme -or
-not [string]::IsNullOrEmpty($Parsed.UserInfo) -or
-not [string]::IsNullOrEmpty($Parsed.Query) -or
-not [string]::IsNullOrEmpty($Parsed.Fragment)
) {
throw "Url must be an absolute HTTP or HTTPS URL"
throw "Url must use HTTPS; HTTP is only allowed for loopback development"
}
}
+216 -106
View File
@@ -2,9 +2,7 @@ use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::io::{self, Read, Write};
#[cfg(any(target_os = "windows", target_os = "macos"))]
use std::net::UdpSocket;
use std::net::{TcpStream, ToSocketAddrs};
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
@@ -50,10 +48,12 @@ struct RuntimeConfig {
report_interval: u64,
collect_interval: u64,
network_interface: String,
auto_update: bool,
latency_tasks: Vec<LatencyTask>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct LatencyTask {
id: String,
name: String,
@@ -70,16 +70,13 @@ struct LatencyResult {
packet_loss: f64,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RemoteConfig {
#[serde(default)]
report_interval: u64,
#[serde(default)]
collect_interval: u64,
#[serde(default)]
network_interface: String,
auto_update: i64,
#[serde(default)]
latency_tasks: Vec<LatencyTask>,
}
@@ -96,8 +93,15 @@ struct GithubReleaseAsset {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReportResponse {
success: bool,
config: RemoteConfig,
}
struct SubmitResult {
config: Option<RemoteConfig>,
config_hash: String,
}
#[derive(Debug, Serialize)]
@@ -120,7 +124,7 @@ struct DiskMetric {
utilization: f64,
}
#[derive(Debug, Default, Clone, Serialize)]
#[derive(Debug, Clone, Serialize)]
struct GpuMetric {
model: String,
usage: f64,
@@ -155,8 +159,6 @@ struct Report {
kernel: String,
arch: String,
virtualization: String,
ipv4: String,
ipv6: String,
gpu_usage: f64,
gpu_model: String,
agent_version: String,
@@ -392,17 +394,6 @@ fn cpu_model() -> String {
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
fn ip_address(family: &str) -> String {
command("ip", &["-o", family, "addr", "show", "scope", "global"])
.lines()
.next()
.and_then(|line| line.split_whitespace().find(|value| value.contains('/')))
.and_then(|value| value.split('/').next())
.unwrap_or("")
.to_string()
}
fn valid_probe_host(host: &str) -> bool {
if host.is_empty() || host.len() > 50 || host.starts_with('.') || host.ends_with('.') {
return false;
@@ -413,24 +404,76 @@ fn valid_probe_host(host: &str) -> bool {
.iter()
.all(|label| !label.is_empty() && label.chars().all(|c| c.is_ascii_digit()));
if ipv4_like {
return labels.iter().all(|label| label.parse::<u8>().is_ok());
return host
.parse()
.is_ok_and(|address| is_public_probe_ip(IpAddr::V4(address)));
}
let lower = host.to_ascii_lowercase();
if labels.len() < 2
|| ["local", "localhost", "internal", "lan", "localdomain"]
.iter()
.any(|suffix| lower == *suffix || lower.ends_with(&format!(".{suffix}")))
|| lower == "home.arpa"
|| lower.ends_with(".home.arpa")
{
return false;
}
labels.iter().all(|label| {
!label.is_empty()
&& label.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
})
&& label
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
&& label
.chars()
.next()
.is_some_and(|character| character.is_ascii_alphanumeric() || character == '_')
.is_some_and(|character| character.is_ascii_alphanumeric())
&& label
.chars()
.last()
.is_some_and(|character| character.is_ascii_alphanumeric() || character == '_')
.is_some_and(|character| character.is_ascii_alphanumeric())
})
}
fn is_public_probe_ip(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => {
let [a, b, c, _] = address.octets();
!(a == 0
|| a == 10
|| a == 127
|| (a == 100 && (64..=127).contains(&b))
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 192 && b == 168)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113)
|| a >= 224)
}
IpAddr::V6(address) => {
if let Some(mapped) = address.to_ipv4_mapped() {
return is_public_probe_ip(IpAddr::V4(mapped));
}
address.segments()[0] & 0xe000 == 0x2000
}
}
}
fn resolve_public_probe_address(host: &str, port: u16) -> Option<SocketAddr> {
let addresses = (host, port)
.to_socket_addrs()
.ok()?
.filter(|address| is_public_probe_ip(address.ip()))
.collect::<Vec<_>>();
addresses
.iter()
.find(|address| address.is_ipv4())
.or_else(|| addresses.first())
.copied()
}
fn parse_probe_target(value: &str) -> Option<(String, u16)> {
let raw = value.trim();
if raw.is_empty()
@@ -466,23 +509,17 @@ fn tcp_latency_probe(target: &str) -> (f64, f64) {
let Some((host, port)) = parse_probe_target(target) else {
return (-1.0, 100.0);
};
let Ok(addresses) = (host.as_str(), port).to_socket_addrs() else {
return (-1.0, 100.0);
};
let addresses = addresses.collect::<Vec<_>>();
let Some(address) = addresses
.iter()
.find(|address| address.is_ipv4())
.or_else(|| addresses.first())
.copied()
else {
let Some(address) = resolve_public_probe_address(&host, port) else {
return (-1.0, 100.0);
};
tcp_latency_probe_address(&address)
}
fn tcp_latency_probe_address(address: &SocketAddr) -> (f64, f64) {
let mut latencies = Vec::with_capacity(PROBE_ATTEMPTS);
for _ in 0..PROBE_ATTEMPTS {
let started = Instant::now();
if TcpStream::connect_timeout(&address, PROBE_TIMEOUT).is_ok() {
if TcpStream::connect_timeout(address, PROBE_TIMEOUT).is_ok() {
latencies.push(started.elapsed().as_secs_f64() * 1000.0);
}
}
@@ -511,19 +548,26 @@ fn ping_latency(output: &str) -> Option<f64> {
fn icmp_latency_probe(target: &str) -> (f64, f64) {
let host = target.trim();
if host.contains(':') || !valid_probe_host(host) {
if host.contains(':') {
return (-1.0, 100.0);
}
let Some((host, _)) = parse_probe_target(host) else {
return (-1.0, 100.0);
};
let Some(address) = resolve_public_probe_address(&host, 443) else {
return (-1.0, 100.0);
};
let destination = address.ip().to_string();
let mut latencies = Vec::with_capacity(PROBE_ATTEMPTS);
for _ in 0..PROBE_ATTEMPTS {
let started = Instant::now();
let mut ping = Command::new("ping");
#[cfg(target_os = "linux")]
ping.args(["-n", "-c", "1", "-W", "1", host]);
ping.args(["-n", "-c", "1", "-W", "1", destination.as_str()]);
#[cfg(target_os = "macos")]
ping.args(["-n", "-c", "1", "-W", "1000", host]);
ping.args(["-n", "-c", "1", "-W", "1000", destination.as_str()]);
#[cfg(target_os = "windows")]
ping.args(["-n", "1", "-w", "1000", host]);
ping.args(["-n", "1", "-w", "1000", destination.as_str()]);
let output = ping.output();
if let Ok(output) = output {
if output.status.success() {
@@ -611,17 +655,6 @@ fn gpu_info() -> Vec<GpuMetric> {
.collect()
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn local_ip(bind: &str, destination: &str) -> String {
UdpSocket::bind(bind)
.and_then(|socket| {
socket.connect(destination)?;
socket.local_addr()
})
.map(|address| address.ip().to_string())
.unwrap_or_default()
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn u64_to_i64(value: u64) -> i64 {
value.min(i64::MAX as u64) as i64
@@ -720,8 +753,6 @@ fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Repor
kernel: command("uname", &["-r"]),
arch: env::consts::ARCH.to_string(),
virtualization: command("systemd-detect-virt", &[]),
ipv4: ip_address("-4"),
ipv6: ip_address("-6"),
gpu_usage,
gpu_model,
agent_version: VERSION.to_string(),
@@ -840,8 +871,6 @@ fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Repor
kernel: System::kernel_version().unwrap_or_default(),
arch: env::consts::ARCH.to_string(),
virtualization: String::new(),
ipv4: local_ip("0.0.0.0:0", "1.1.1.1:80"),
ipv6: local_ip("[::]:0", "[2606:4700:4700::1111]:80"),
gpu_usage,
gpu_model,
agent_version: VERSION.to_string(),
@@ -886,7 +915,9 @@ fn runtime_config() -> Result<RuntimeConfig> {
return Err("SERVER_ID or AGENT_TOKEN is invalid".into());
}
if !valid_worker_url(&worker_url) {
return Err("WORKER_URL must be an http(s) URL without whitespace".into());
return Err(
"WORKER_URL must use HTTPS; HTTP is only allowed for loopback development".into(),
);
}
Ok(RuntimeConfig {
server_id,
@@ -895,6 +926,7 @@ fn runtime_config() -> Result<RuntimeConfig> {
report_interval: interval,
collect_interval,
network_interface: env::var("NETWORK_INTERFACE").unwrap_or_default(),
auto_update: true,
latency_tasks: Vec::new(),
})
}
@@ -903,17 +935,38 @@ fn submit(
agent: &ureq::Agent,
config: &RuntimeConfig,
reports: &[Report],
) -> Result<Option<RemoteConfig>> {
config_hash: &str,
) -> Result<SubmitResult> {
let batch = ReportBatch {
server_id: &config.server_id,
samples: reports,
};
let response = agent
let mut response = agent
.post(&format!("{}/api/agent/report", config.worker_url))
.set("Authorization", &format!("Bearer {}", config.token))
.set("User-Agent", &format!("nodeflare-agent/{VERSION}"))
.header("Authorization", format!("Bearer {}", config.token))
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
.header("X-Agent-Config-Schema", "1")
.header("X-Agent-Config-Sha256", config_hash)
.send_json(batch)?;
Ok(response.into_json::<ReportResponse>()?.config)
let next_hash = response
.headers()
.get("X-Agent-Config-Sha256")
.and_then(|value| value.to_str().ok())
.unwrap_or(config_hash)
.to_string();
let remote = if response.status().as_u16() == 204 {
None
} else {
let payload = response.body_mut().read_json::<ReportResponse>()?;
if !payload.success {
return Err("Worker rejected the report".into());
}
Some(payload.config)
};
Ok(SubmitResult {
config: remote,
config_hash: next_hash,
})
}
fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool {
@@ -926,6 +979,7 @@ fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool {
config
.network_interface
.clone_from(&remote.network_interface);
config.auto_update = remote.auto_update == 1;
let tasks = sanitize_latency_tasks(&remote.latency_tasks);
let changed = config.latency_tasks != tasks;
config.latency_tasks = tasks;
@@ -954,15 +1008,32 @@ fn sanitize_latency_tasks(tasks: &[LatencyTask]) -> Vec<LatencyTask> {
fn valid_worker_url(value: &str) -> bool {
let value = value.trim_end_matches('/');
let authority = value
.strip_prefix("https://")
.or_else(|| value.strip_prefix("http://"))
.and_then(|remainder| remainder.split('/').next());
value.len() <= 2048
&& authority.is_some_and(|authority| !authority.is_empty() && !authority.contains('@'))
&& !value
.chars()
.any(|character| character.is_whitespace() || matches!(character, '?' | '#'))
if value.is_empty() || value.len() > 2048 || value.chars().any(char::is_whitespace) {
return false;
}
let Ok(parsed) = url::Url::parse(value) else {
return false;
};
if parsed.host().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return false;
}
if parsed.scheme() == "https" {
return true;
}
if parsed.scheme() != "http" {
return false;
}
match parsed.host() {
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(address)) => address == std::net::Ipv4Addr::LOCALHOST,
Some(url::Host::Ipv6(address)) => address.is_loopback(),
None => false,
}
}
fn report_retry_delay(failures: u32, report_interval: u64) -> Duration {
@@ -1037,7 +1108,11 @@ fn sha256_file(path: &Path) -> Result<String> {
}
digest.update(&buffer[..read]);
}
Ok(format!("{:x}", digest.finalize()))
Ok(digest
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
fn parse_checksum(contents: &str, artifact: &str) -> Option<String> {
@@ -1058,6 +1133,7 @@ fn release_checksum(agent: &ureq::Agent, url: &str, artifact: &str) -> Result<St
agent
.get(url)
.call()?
.into_body()
.into_reader()
.take(1024 * 1024)
.read_to_string(&mut contents)?;
@@ -1075,7 +1151,7 @@ fn download_agent(
let Ok(response) = agent.get(url).call() else {
return Ok(false);
};
let response = response.into_reader();
let response = response.into_body().into_reader();
let mut file = fs::File::create(destination)?;
let copied = io::copy(&mut response.take(MAX_AGENT_BINARY_BYTES + 1), &mut file)?;
file.flush()?;
@@ -1105,12 +1181,12 @@ fn download_agent(
}
fn update(agent: &ureq::Agent) -> Result<bool> {
let release = agent
let mut response = agent
.get(LATEST_RELEASE_API)
.set("Accept", "application/vnd.github+json")
.set("User-Agent", &format!("nodeflare-agent/{VERSION}"))
.call()?
.into_json::<GithubRelease>()?;
.header("Accept", "application/vnd.github+json")
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
.call()?;
let release = response.body_mut().read_json::<GithubRelease>()?;
let Some(remote_version) = version_triplet(&release.tag_name) else {
return Ok(false);
};
@@ -1199,9 +1275,10 @@ fn update(agent: &ureq::Agent) -> Result<bool> {
fn run(once: bool, print_only: bool) -> Result<()> {
let mut config = runtime_config()?;
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(20))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(20)))
.build()
.into();
let mut next_report = Instant::now();
let mut next_collect = Instant::now();
let mut next_latency: HashMap<String, Instant> = HashMap::new();
@@ -1209,6 +1286,7 @@ fn run(once: bool, print_only: bool) -> Result<()> {
let mut pending_samples = Vec::new();
let mut report_failures = 0_u32;
let mut next_update_check = Instant::now();
let mut config_hash = String::new();
loop {
let current = Instant::now();
let due = config
@@ -1252,27 +1330,28 @@ fn run(once: bool, print_only: bool) -> Result<()> {
}
if Instant::now() >= next_report && !pending_samples.is_empty() {
match submit(&agent, &config, &pending_samples) {
Ok(remote) => {
match submit(&agent, &config, &pending_samples, &config_hash) {
Ok(result) => {
pending_samples.clear();
report_failures = 0;
if let Some(remote) = remote {
if !once && remote.auto_update == 1 && Instant::now() >= next_update_check {
match update(&agent) {
Ok(true) => return Ok(()),
Ok(false) => {
next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL;
}
Err(error) => {
eprintln!("agent update failed: {error}");
next_update_check = Instant::now() + UPDATE_RETRY_INTERVAL;
}
}
}
config_hash = result.config_hash;
if let Some(remote) = result.config {
if apply_remote(&mut config, &remote) {
next_latency.clear();
}
}
if !once && config.auto_update && Instant::now() >= next_update_check {
match update(&agent) {
Ok(true) => return Ok(()),
Ok(false) => {
next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL;
}
Err(error) => {
eprintln!("agent update failed: {error}");
next_update_check = Instant::now() + UPDATE_RETRY_INTERVAL;
}
}
}
}
Err(error) => {
eprintln!("report failed: {error}");
@@ -1329,10 +1408,10 @@ mod tests {
use std::time::Duration;
use super::{
agent_artifact_name, executable_format_valid, median, normalized_version, parse_checksum,
parse_probe_target, ping_latency, prune_report_samples, report_retry_delay,
sanitize_latency_tasks, selected_interface, tcp_latency_probe, valid_worker_url,
version_triplet, LatencyTask, Report, MAX_LATENCY_TASKS, PROBE_ATTEMPTS,
agent_artifact_name, executable_format_valid, is_public_probe_ip, median,
normalized_version, parse_checksum, parse_probe_target, ping_latency, prune_report_samples,
report_retry_delay, sanitize_latency_tasks, selected_interface, tcp_latency_probe_address,
valid_worker_url, version_triplet, LatencyTask, Report, MAX_LATENCY_TASKS, PROBE_ATTEMPTS,
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
@@ -1380,6 +1459,10 @@ mod tests {
fn validates_worker_urls() {
assert!(valid_worker_url("https://monitor.example.com/"));
assert!(valid_worker_url("http://127.0.0.1:8787"));
assert!(valid_worker_url("http://localhost:8787"));
assert!(valid_worker_url("http://[::1]:8787"));
assert!(!valid_worker_url("http://monitor.example.com"));
assert!(!valid_worker_url("http://127.0.0.2:8787"));
assert!(!valid_worker_url("monitor.example.com"));
assert!(!valid_worker_url("https://"));
assert!(!valid_worker_url("https://user@example.com"));
@@ -1475,8 +1558,8 @@ mod tests {
Some(("example.com".to_string(), 443))
);
assert_eq!(
parse_probe_target("127.0.0.1:8080"),
Some(("127.0.0.1".to_string(), 8080))
parse_probe_target("1.1.1.1:8080"),
Some(("1.1.1.1".to_string(), 8080))
);
for target in [
"",
@@ -1484,6 +1567,11 @@ mod tests {
"example.com:0",
"example.com:65536",
"999.1.1.1",
"127.0.0.1:8080",
"169.254.169.254",
"192.168.1.1",
"router.local",
"localhost",
"[::1]:443",
"bad host",
] {
@@ -1491,6 +1579,29 @@ mod tests {
}
}
#[test]
fn accepts_only_public_probe_addresses() {
for address in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
assert!(is_public_probe_ip(address.parse().expect("IP address")));
}
for address in [
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"192.168.1.1",
"198.18.0.1",
"224.0.0.1",
"::1",
"fe80::1",
"fd00:ec2::254",
] {
assert!(!is_public_probe_ip(address.parse().expect("IP address")));
}
}
#[test]
fn calculates_median() {
assert_eq!(median(&mut [4.0, 1.0, 3.0]), 3.0);
@@ -1506,11 +1617,10 @@ mod tests {
listener.accept().expect("accept probe connection");
}
});
let (latency, loss) = tcp_latency_probe(&address.to_string());
let (latency, loss) = tcp_latency_probe_address(&address);
accepter.join().expect("join listener");
assert!(latency >= 0.0);
assert_eq!(loss, 0.0);
assert_eq!(tcp_latency_probe(""), (-1.0, -1.0));
}
#[test]
+116 -168
View File
@@ -5,81 +5,41 @@
"": {
"name": "nodeflare",
"devDependencies": {
"wrangler": "^4.120.0",
"wrangler": "^4.120.1",
},
},
"frontend": {
"name": "nodeflare-ui",
"version": "0.0.1",
"dependencies": {
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"recharts": "^2.15.4",
"lucide-react": "^1.31.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^7.0.2",
"vite": "^8.2.1",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@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.20260801.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-wuJWbXpKvncJi1P0GKS+iYpN5tHdb7JPJJ/+6ZQe8zzovHHVMkLJPNBsgWpqeUhpM3g9qTwEKd2rglNKejuh5A=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260804.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-191/PPEFicRK2wK69eXzSjnLgHL79k7zR2VUpyIr8rhFgGns1b5bTHgnBuUrgU2LPbEtwbE5eL2hJ0uhLAFEow=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260801.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kwoZiTpnhNrF3+APx84Q/oAqvJ3sU9yefGagwm/ASaH/2W19x0vghkW/r4qCoHCK0WW7EPugZ+aXjgPMRtlq1Q=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260804.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aI2cAFLsrNkSz3kLSQrgrO9ICTfY7jb2h0jgaWDE9mQLQQDfpXeYrKWt07tTgMmeNP79o9w3NZe3aqtt8mTGHQ=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260801.1", "", { "os": "linux", "cpu": "x64" }, "sha512-r0vAxCZH+Jih9Unm1yoyiByPNWNgawcKciOHDm5Q37ZVGOkKLsT9AtLe3yLSaul76WrKqtf+JP2n0WW32VBLJg=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260804.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KBCjxBIlN2jucfQGaTK4EgmPsWzQgYR/zYhPfi3mkWwdoTyG1dgrt2aizKps/SYse85ci/SOxojKk0/K7vstPw=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260801.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-zWgpdZtSozvIgzQNmQiDSF8yEOQJUkRAWNsDXXzAAoy+fCn8YUoSibj3mpFSbZRvbUldeBEaW6SCdC2VEMkhNQ=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260804.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-7lswfarBZ7xkHpTFrNb74ExwOltIalVaASc+GOYfCNtnwFxK/JZSVByfm/hnZEBtrHU7fMY2z7dYT64rwS0pHg=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260801.1", "", { "os": "win32", "cpu": "x64" }, "sha512-2oQz+Ksu4ji6e/+ZoYX+tWQEcxAii2p7l+iR8kx48W1llMalaufAsmVxTlk+3/vrM7D3/2c0iK448e0UQTcIMg=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260804.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GOgRWYtxISN5rAWfx3K7zubt3xEn0/ZPFrbcLL+GwT4ouCKyNoHqfT1cPNB6Nx7t8BHEjnuTG7gkHO4Wd5ORdg=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -191,17 +151,13 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@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=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
@@ -209,69 +165,45 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@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=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
"@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=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -291,28 +223,58 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
"nodeflare-ui": ["nodeflare-ui@workspace:frontend"],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -339,59 +301,59 @@
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.402", "", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="],
"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=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"fast-equals": ["fast-equals@5.4.1", "", {}, "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ=="],
"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=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"immer": ["immer@11.1.16", "", {}, "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"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=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"miniflare": ["miniflare@5.20260801.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260801.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-BHPVzIDA6mbx7LefxpvkXW7DHx9FKB9GorZatbnrrFTt3CVMU8zuUpbgyCuebwDKcTTOZos43ta8GQ0eMVEpxA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
"miniflare": ["miniflare@5.20260804.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260804.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-UDegnTukIpJCI/lNyQXU7B8kYFLcMZihlxqqOocnc62tV8AO6XofmFmZm3ZLDXTWZpkZnLLJ2sKCSlTIEXO3dQ=="],
"nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"nodeflare-ui": ["nodeflare-ui@workspace:frontend"],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
@@ -403,29 +365,27 @@
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"recharts": ["recharts@3.10.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
"rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="],
"rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"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=="],
@@ -439,38 +399,26 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"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=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"update-browserslist-db": ["update-browserslist-db@1.3.0", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA=="],
"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@36.9.2", "", { "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-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"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@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.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-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"workerd": ["workerd@1.20260801.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260801.1", "@cloudflare/workerd-darwin-arm64": "1.20260801.1", "@cloudflare/workerd-linux-64": "1.20260801.1", "@cloudflare/workerd-linux-arm64": "1.20260801.1", "@cloudflare/workerd-windows-64": "1.20260801.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/g9JGTyqnHtoIscpBHqKD8swE2V4StBs2i69PmLiOhH45OP95jCFICl4F1hKlgN57rqfni5LiCitttoX5OOkVA=="],
"workerd": ["workerd@1.20260804.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260804.1", "@cloudflare/workerd-darwin-arm64": "1.20260804.1", "@cloudflare/workerd-linux-64": "1.20260804.1", "@cloudflare/workerd-linux-arm64": "1.20260804.1", "@cloudflare/workerd-windows-64": "1.20260804.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-b0P38g5/ssemwWxd/mafNYggEZ0ere7PCiUH6RCHkgqjRhhXTP45nDiL2L3iIvi8uF2IjNrGpVgMXEunCaeY/w=="],
"wrangler": ["wrangler@4.120.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.20260801.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260801.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260801.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-cBmu/MeaB/fPacC0JpATs4duTOCagBxrZo+vBzuTX06tLzwSyAHE1drlHUZ8rP0VqVz1fy3ReGYTiHdKkoHltg=="],
"wrangler": ["wrangler@4.120.1", "", { "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.20260804.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260804.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260804.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-rQJ38rY02XiVITbSBWHC7oap3M2evcXgsC4CdlIX4WQENUZMMnue9j2vXO4aIi39KR+jDH+UHK8JNqp1+UjkRQ=="],
"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=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"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=="],
"@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
}
}
+9 -9
View File
@@ -12,16 +12,16 @@
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"recharts": "^2.15.4"
"lucide-react": "^1.31.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.6"
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^7.0.2",
"vite": "^8.2.1"
}
}
+3 -1
View File
@@ -152,7 +152,9 @@ export default function App() {
if (proof) endpoint.searchParams.set("turnstile_verified", proof);
const socket = new WebSocket(endpoint);
wsRef.current.push(socket);
socket.onopen = () => void load(true);
socket.onclose = () => {
wsRef.current = wsRef.current.filter((current) => current !== socket);
if (!cancelled) reconnects.push(window.setTimeout(connect, 3000));
};
socket.onmessage = (event) => {
@@ -175,7 +177,7 @@ export default function App() {
wsRef.current.forEach((socket) => socket.close());
wsRef.current = [];
};
}, [config.websocket, config.public_dashboard, config.turnstile_enabled, loading, needsVerification, selectedId]);
}, [config.websocket, config.public_dashboard, config.turnstile_enabled, load, loading, needsVerification, selectedId]);
const groups = useMemo(() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))], [servers]);
const visible = useMemo(() => servers.filter((server) => {
+14 -5
View File
@@ -1,4 +1,4 @@
import type { AlertRule, AlertRuleInput, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, ThemeSettingsSchema } from "./types";
import type { AdminServer, AlertRule, AlertRuleInput, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, Theme, ThemeSettingsSchema } from "./types";
const TOKEN_KEY = "nodeflare-admin-token";
const TURNSTILE_KEY = "nodeflare-turnstile-verified";
@@ -45,8 +45,8 @@ export const api = {
config: () => request<Config>("/api/config"),
exchangeRates: () => request<ExchangeRates>("/api/exchange-rates"),
refreshExchangeRates: () => request<ExchangeRates>("/api/admin/exchange-rates/refresh", { method: "POST" }, true),
servers: (admin = false) =>
request<{ servers: Server[] }>(admin ? "/api/admin/servers" : "/api/servers", {}, admin),
servers: () => request<{ servers: Server[] }>("/api/servers"),
adminServers: () => request<{ servers: AdminServer[] }>("/api/admin/servers", {}, true),
server: (id: string) => request<Server>(`/api/servers/${encodeURIComponent(id)}`),
history: (id: string, hours: number) =>
request<{ points: HistoryPoint[] }>(`/api/history/${encodeURIComponent(id)}?hours=${hours}`),
@@ -54,10 +54,10 @@ export const api = {
request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`),
verifyTurnstile: (token: string) =>
request<{ verification: string }>("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }),
login: (username: string, password: string, turnstileToken: string) =>
login: (username: string, password: string, passwordDerived: string, turnstileToken: string) =>
request<{ token: string }>("/api/admin/login", {
method: "POST",
body: JSON.stringify({ username, password, turnstile_token: turnstileToken }),
body: JSON.stringify({ username, password, password_derived: passwordDerived, turnstile_token: turnstileToken }),
}),
settings: () => request<Settings>("/api/admin/settings", {}, true),
latencyTasks: () => request<{ tasks: LatencyTask[] }>("/api/admin/latency-tasks", {}, true),
@@ -75,6 +75,15 @@ export const api = {
deleteAlertRule: (id: string) =>
request<{ success: boolean }>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
themeSettings: () => request<ThemeSettingsSchema>("/api/admin/theme-settings", {}, true),
themes: () => request<{ themes: Theme[] }>("/api/admin/themes", {}, true),
addTheme: (input: Pick<Theme, "name" | "description" | "url">) =>
request<{ id: string }>("/api/admin/themes", { method: "POST", body: JSON.stringify(input) }, true),
activateTheme: (id: string) =>
request<{ success: boolean }>(`/api/admin/themes/${encodeURIComponent(id)}/activate`, { method: "POST" }, true),
previewTheme: (id: string) =>
request<{ preview_url: string }>(`/api/admin/themes/${encodeURIComponent(id)}/preview`, { method: "POST" }, true),
deleteTheme: (id: string) =>
request<{ success: boolean }>(`/api/admin/themes/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
saveSettings: (input: Partial<Settings>) =>
request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true),
createServer: (input: ServerInput) =>
+116 -22
View File
@@ -23,11 +23,14 @@ import {
SlidersHorizontal,
Sun,
Trash2,
Check,
ExternalLink,
} from "lucide-react";
import { DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { api, ApiError, getToken, setToken } from "../api";
import { formatBytes, isOnline } from "../format";
import { ASSET_CURRENCIES, type AgentInstallTarget, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type Server, type ServerInput, type Settings, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types";
import { derivePassword } from "../password";
import { ASSET_CURRENCIES, type AdminServer, type AgentInstallTarget, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types";
import { Checkbox } from "./Checkbox";
import { TurnstileWidget } from "./TurnstileWidget";
import { LatencyManager } from "./LatencyManager";
@@ -40,7 +43,7 @@ const adminPages: Record<AdminTab, { title: string; description: string }> = {
servers: { title: "服务器", description: "管理监控节点、Agent 密钥和运行参数" },
latency: { title: "延迟监测", description: "配置分配给各服务器的 TCP 与 ICMP 延迟任务" },
appearance: { title: "站点设置", description: "调整站点信息、公开内容和前台显示项目" },
themes: { title: "主题管理", description: "管理内置前端主题和公开访问" },
themes: { title: "主题商店", description: "选择内置主题或安装远程主题" },
themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" },
alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" },
security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" },
@@ -73,7 +76,7 @@ const emptyServer: ServerInput = {
auto_update: true,
};
function toInput(server: Server): ServerInput {
function toInput(server: AdminServer): ServerInput {
return {
...emptyServer,
name: server.name,
@@ -139,15 +142,19 @@ export function AdminPanel({
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [tab, setTab] = useState<AdminTab>("servers");
const [servers, setServers] = useState<Server[]>([]);
const [servers, setServers] = useState<AdminServer[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [draggingId, setDraggingId] = useState("");
const [settings, setSettings] = useState<Settings | null>(null);
const [database, setDatabase] = useState<DatabaseStats | null>(null);
const [cloudflareUsage, setCloudflareUsage] = useState<CloudflareUsage | null>(null);
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
const [themes, setThemes] = useState<Theme[]>([]);
const [themeName, setThemeName] = useState("");
const [themeDescription, setThemeDescription] = useState("");
const [themeUrl, setThemeUrl] = useState("");
const [themeSettingsSchema, setThemeSettingsSchema] = useState<ThemeSettingsSchema | null>(null);
const [editing, setEditing] = useState<Server | "new" | null>(null);
const [editing, setEditing] = useState<AdminServer | "new" | null>(null);
const [form, setForm] = useState<ServerInput>(emptyServer);
const [install, setInstall] = useState<AgentInstallTarget[]>([]);
const [installPlatform, setInstallPlatform] = useState<AgentPlatform>("linux");
@@ -157,8 +164,9 @@ export function AdminPanel({
setBusy(true);
setError("");
try {
const [serverResult, settingsResult] = await Promise.all([api.servers(true), api.settings()]);
const [serverResult, settingsResult, themesResult] = await Promise.all([api.adminServers(), api.settings(), api.themes()]);
setServers(serverResult.servers);
setThemes(themesResult.themes);
setSettings({
...settingsResult,
site_announcement: settingsResult.site_announcement || "",
@@ -193,7 +201,8 @@ export function AdminPanel({
setBusy(true);
setError("");
try {
const result = await api.login(username.trim(), password, turnstileToken);
const passwordDerived = await derivePassword(password, config.password_client_salt);
const result = await api.login(username.trim(), password, passwordDerived, turnstileToken);
setToken(result.token);
setPassword("");
setTurnstileToken("");
@@ -206,7 +215,7 @@ export function AdminPanel({
} finally { setBusy(false); }
}
function openEditor(server?: Server) {
function openEditor(server?: AdminServer) {
setEditing(server ?? "new");
setForm(server ? toInput(server) : { ...emptyServer });
setError("");
@@ -243,13 +252,13 @@ export function AdminPanel({
finally { setBusy(false); }
}
async function remove(server: Server) {
async function remove(server: AdminServer) {
if (!window.confirm(`确认删除“${server.name}”及其全部历史数据?`)) return;
try { await api.deleteServer(server.id); await load(); onChanged(); }
catch (reason) { setError(reason instanceof Error ? reason.message : "删除失败"); }
}
async function rotate(server: Server) {
async function rotate(server: AdminServer) {
if (!window.confirm(`重置“${server.name}”的 Agent 密钥?旧 Agent 将立即失效。`)) return;
try {
const result = await api.rotateToken(server.id);
@@ -296,9 +305,15 @@ export function AdminPanel({
setBusy(true);
setError("");
setNotice("");
const payload = { ...settings };
if (!payload.new_password) delete payload.new_password;
const payload: Partial<Settings> = { ...settings };
delete payload.admin_password_configured;
try {
if (payload.new_password) {
payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt);
} else {
delete payload.new_password;
delete payload.new_password_derived;
}
const result = await api.saveSettings(payload);
if (result.token) setToken(result.token);
setSettings(result.settings);
@@ -350,6 +365,59 @@ export function AdminPanel({
finally { setBusy(false); }
}
async function addTheme(event: FormEvent) {
event.preventDefault();
setBusy(true); setError(""); setNotice("");
try {
await api.addTheme({ name: themeName.trim(), description: themeDescription.trim(), url: themeUrl.trim() });
setThemeName(""); setThemeDescription(""); setThemeUrl("");
await load();
setNotice("主题已添加");
} catch (reason) { setError(reason instanceof Error ? reason.message : "添加主题失败"); }
finally { setBusy(false); }
}
async function activateTheme(theme: Theme) {
if (theme.active) return;
setBusy(true); setError(""); setNotice("");
try {
await api.activateTheme(theme.id);
await load();
setNotice(`已启用主题:${theme.name}`);
onChanged();
} catch (reason) { setError(reason instanceof Error ? reason.message : "启用主题失败"); }
finally { setBusy(false); }
}
async function previewTheme(theme: Theme) {
if (theme.builtin) return;
const previewWindow = window.open("", "_blank");
setBusy(true); setError("");
try {
const { preview_url } = await api.previewTheme(theme.id);
if (previewWindow) {
previewWindow.opener = null;
previewWindow.location.replace(preview_url);
} else {
window.open(preview_url, "_blank", "noopener,noreferrer");
}
} catch (reason) {
previewWindow?.close();
setError(reason instanceof Error ? reason.message : "创建主题预览失败");
} finally { setBusy(false); }
}
async function removeTheme(theme: Theme) {
if (theme.builtin || !window.confirm(`确认删除主题“${theme.name}”?`)) return;
setBusy(true); setError(""); setNotice("");
try {
await api.deleteTheme(theme.id);
await load();
setNotice("主题已删除");
} catch (reason) { setError(reason instanceof Error ? reason.message : "删除主题失败"); }
finally { setBusy(false); }
}
async function clearHistory() {
if (!window.confirm("确认清空全部历史指标?节点配置和最新状态不会删除。")) return;
setBusy(true); setError("");
@@ -431,7 +499,7 @@ export function AdminPanel({
<button className={tab === "servers" ? "active" : ""} onClick={() => selectTab("servers")}><ServerCog size={19} />服务器</button>
<button className={tab === "latency" ? "active" : ""} onClick={() => selectTab("latency")}><RadioTower size={19} />延迟监测</button>
<button className={tab === "appearance" ? "active" : ""} onClick={() => selectTab("appearance")}><Eye size={19} />站点设置</button>
<button className={tab === "themes" ? "active" : ""} onClick={() => selectTab("themes")}><Palette size={19} />主题管理</button>
<button className={tab === "themes" ? "active" : ""} onClick={() => selectTab("themes")}><Palette size={19} />主题商店</button>
<button className={tab === "themeSettings" ? "active" : ""} onClick={() => { selectTab("themeSettings"); void loadThemeSettings(); }}><SlidersHorizontal size={19} />主题设置</button>
<button className={tab === "alerts" ? "active" : ""} onClick={() => selectTab("alerts")}><AlertTriangle size={19} />通知</button>
<button className={tab === "security" ? "active" : ""} onClick={() => selectTab("security")}><ShieldCheck size={19} />登录与安全</button>
@@ -463,14 +531,40 @@ export function AdminPanel({
) : tab === "latency" ? (
<LatencyManager servers={servers} onError={setError} onNotice={setNotice} />
) : tab === "themes" && settings ? (
<form className="settings-form" onSubmit={saveSite}>
<div className="section-title"><Palette size={15} />内置主题</div>
<div className="builtin-theme-row"><div><strong>Komari Glass</strong><span>内置前端主题</span></div><b>使用中</b></div>
<div className="section-subtitle">公开访问</div>
<Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />
<p className="settings-hint">关闭后,未登录访客无法读取节点和历史数据。</p>
<div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存主题管理设置</button></div>
</form>
<div className="theme-store-page">
<section className="admin-section">
<div className="section-head"><div><h3>主题列表</h3><span>内置主题始终可用,远程主题由 Worker 代理前台页面和资源。</span></div></div>
<div className="theme-store-grid">
{themes.map((theme) => <article className={`theme-store-card ${theme.active ? "active" : ""}`} key={theme.id}>
<div className="theme-card-icon"><Palette size={20} /></div>
<div className="theme-card-main">
<div className="theme-card-title"><strong>{theme.name}</strong><span className={`theme-badge ${theme.builtin ? "builtin" : "remote"}`}>{theme.builtin ? "默认主题" : "远程主题"}</span></div>
<p>{theme.description || "暂无主题说明"}</p>
{!theme.builtin ? <a href={theme.url} target="_blank" rel="noreferrer"><span>{theme.url}</span><ExternalLink size={13} /></a> : <small>NodeFlare 内置 Komari Glass</small>}
</div>
<div className="theme-card-actions">
{!theme.builtin ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void previewTheme(theme)}><Eye size={14} />预览</button> : null}
<button type="button" className={theme.active ? "theme-active-btn" : "primary-btn compact"} disabled={busy || theme.active} onClick={() => void activateTheme(theme)}>{theme.active ? <><Check size={14} />使用中</> : "启用"}</button>
{!theme.builtin ? <button type="button" className="icon-btn danger" disabled={busy} title="删除主题" onClick={() => void removeTheme(theme)}><Trash2 size={15} /></button> : null}
</div>
</article>)}
</div>
</section>
<form className="admin-section theme-add-form" onSubmit={addTheme}>
<div className="section-title"><Plus size={15} />添加远程主题</div>
<p className="settings-hint">填写 GitHub <code>tree</code> 地址,对应目录需包含 <code>index.html</code> 和 <code>assets/</code> 目录。</p>
<div className="form-grid"><label><span>主题名称</span><input required maxLength={80} value={themeName} onChange={(event) => setThemeName(event.target.value)} placeholder="例如:Ocean" /></label><label><span>主题 URL</span><input required type="url" maxLength={2048} value={themeUrl} onChange={(event) => setThemeUrl(event.target.value)} placeholder="https://github.com/user/theme/tree/main" /></label></div>
<label><span>主题说明(可选)</span><textarea rows={2} maxLength={300} value={themeDescription} onChange={(event) => setThemeDescription(event.target.value)} placeholder="简短描述主题风格和来源" /></label>
<div className="theme-risk-note">远程主题包含第三方 HTML、JavaScript 和 CSS。仅添加你信任的主题仓库。</div>
<div className="form-actions"><button className="primary-btn" disabled={busy}><Plus size={16} />添加主题</button></div>
</form>
<form className="admin-section" onSubmit={saveSite}>
<div className="section-title"><Eye size={15} />公开访问</div>
<Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />
<p className="settings-hint">关闭后,未登录访客无法读取节点和历史数据。</p>
<div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存公开设置</button></div>
</form>
</div>
) : settings ? (
<form className="settings-form" onSubmit={saveSite}>
{tab === "appearance" ? <>
@@ -492,7 +586,7 @@ export function AdminPanel({
{tab === "alerts" ? <>
<div className="section-title"><AlertTriangle size={15} />通知与告警</div>
<div className="form-grid"><label><span>Telegram Bot Token</span><input autoComplete="off" value={settings.notification_endpoint} onChange={(event) => updateSettings("notification_endpoint", event.target.value)} placeholder="123456789:AA..." /></label><label><span>Telegram Chat ID</span><input value={settings.notification_target} onChange={(event) => updateSettings("notification_target", event.target.value)} placeholder="个人、群组或频道 ID" /></label></div>
<div className="form-grid"><label><span>Telegram Bot Token</span><input autoComplete="off" type="password" value={settings.notification_endpoint} onChange={(event) => updateSettings("notification_endpoint", event.target.value)} placeholder="123456789:AA..." /></label><label><span>Telegram Chat ID</span><input value={settings.notification_target} onChange={(event) => updateSettings("notification_target", event.target.value)} placeholder="个人、群组或频道 ID" /></label></div>
<Toggle label="启用通知与告警" checked={settings.notification_enabled} onChange={(value) => updateSettings("notification_enabled", value)} />
<div className="form-grid three"><label><span>离线告警延迟(分钟)</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>到期提醒(天)</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>通知测试</span><button type="button" className="secondary-btn" disabled={busy} onClick={() => void testNotification()}>发送测试通知</button></label></div>
<p className="settings-hint">通过 Telegram Bot 发送告警;告警状态会记录在 D1,同一故障和恢复只发送一次。</p>
+15 -2
View File
@@ -19,7 +19,20 @@ function validHost(value: string) {
const [name, port] = host.split(":");
if (port && (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535)) return false;
if (!name || name.startsWith(".") || name.endsWith(".")) return false;
return name.split(".").every((part) => /^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$/.test(part));
const labels = name.split(".");
if (labels.length === 4 && labels.every((part) => /^\d+$/.test(part))) {
const octets = labels.map(Number);
if (octets.some((part) => part < 0 || part > 255)) return false;
const [a, b, c] = octets;
return !(a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127)
|| (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 168)
|| (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
|| (a === 203 && b === 0 && c === 113) || a >= 224);
}
const lower = name.toLowerCase();
if (labels.length < 2 || ["local", "localhost", "internal", "lan", "localdomain"].some((suffix) => lower === suffix || lower.endsWith(`.${suffix}`)) || lower === "home.arpa" || lower.endsWith(".home.arpa")) return false;
return labels.every((part) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(part));
}
export function LatencyManager({
@@ -83,7 +96,7 @@ export function LatencyManager({
async function save(event: FormEvent) {
event.preventDefault();
if (!validHost(form.target) || (form.task_type === "icmp" && form.target.includes(":"))) {
onError(form.task_type === "icmp" ? "ICMP 目标应为域名或 IPv4,不能包含端口" : "TCP 目标应为域名、IPv4 或 host:port");
onError(form.task_type === "icmp" ? "ICMP 目标应为公网域名或公网 IPv4,不能包含端口" : "TCP 目标应为公网域名、公网 IPv4 或 host:port");
return;
}
if (!form.default_enabled && !form.server_ids.length) {
+1
View File
@@ -2,6 +2,7 @@ import { MonitorCog } from "lucide-react";
import { useState } from "react";
const systems = [
["alibaba", "os-alibaba.svg", "Alibaba Cloud Linux"],
["alma", "os-alma.svg", "AlmaLinux"], ["alpine", "os-alpine.webp", "Alpine Linux"],
["arch", "os-arch.svg", "Arch Linux"], ["armbian", "os-armbian.png", "Armbian"],
["centos", "os-centos.svg", "CentOS"], ["debian", "os-debian.svg", "Debian"],
+12 -24
View File
@@ -10,6 +10,7 @@ export const demoConfig: Config = {
offline_threshold_seconds: 180,
history_retention_days: 30,
default_theme: "system",
active_theme_id: "builtin-komari-glass",
background_url: "",
theme_options: {},
show_search: true,
@@ -25,6 +26,7 @@ export const demoConfig: Config = {
turnstile_enabled: false,
turnstile_login_enabled: true,
turnstile_site_key: "",
password_client_salt: "nodeflare-demo-password-kdf",
websocket: true,
};
@@ -77,9 +79,6 @@ const baseServer: Server = {
region: "",
group_name: "默认",
tags: "",
note: "",
hidden: 0,
sort_order: 0,
expires_at: null,
traffic_limit: 0,
traffic_limit_type: "sum",
@@ -88,16 +87,7 @@ const baseServer: Server = {
currency: "CNY",
auto_renewal: 0,
public_remark: "",
network_interface: "",
reset_day: 1,
report_interval: 60,
collect_interval: 5,
rx_correction: 0,
tx_correction: 0,
offline_notify_disabled: 0,
auto_update: 1,
created_at: now - 365 * 86400,
updated_at: now,
timestamp: now - 12,
cpu: 24,
load1: 0.42,
@@ -123,8 +113,6 @@ const baseServer: Server = {
kernel: "6.1.0",
arch: "x86_64",
virtualization: "KVM",
ipv4: "203.0.113.8",
ipv6: "2001:db8::8",
gpu_usage: 0,
gpu_model: "",
agent_version: "0.0.1",
@@ -140,21 +128,21 @@ const baseServer: Server = {
latency: [],
};
function node(sort_order: number, input: Partial<Server> & Pick<Server, "id" | "name">): Server {
const server = { ...baseServer, ...input, sort_order };
function node(input: Partial<Server> & Pick<Server, "id" | "name">): Server {
const server = { ...baseServer, ...input };
server.latency = input.latency ?? demoLatestLatency(server.id);
return server;
}
export const demoServers: Server[] = [
node(0, { id: "hongkong-edge", name: "香港 Edge 01", region: "HK", group_name: "边缘网络", tags: "主力,线路:BGP,用途:网站", price: 128, expires_at: now + 46 * 86400, traffic_limit: 2 * 1024 ** 4 }),
node(1, { id: "tokyo-core", name: "东京 Core", region: "JP", group_name: "核心服务", tags: "主力,线路:Premium", price: 9.9, currency: "USD", expires_at: now + 46 * 86400, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, disk_total: 224 * 1024 ** 3, disk_used: 69.44 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 810 * 1024 ** 3, net_tx_total: 310 * 1024 ** 3, cpu: 31 }),
node(2, { id: "singapore-data", name: "新加坡 Data", region: "SG", group_name: "数据服务", tags: "数据库,NVMe", price: 88, expires_at: now + 46 * 86400, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, disk_total: 288 * 1024 ** 3, disk_used: 89.28 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 980 * 1024 ** 3, net_tx_total: 400 * 1024 ** 3, cpu: 38 }),
node(3, { id: "los-angeles-west", name: "洛杉矶 West", region: "US", group_name: "边缘网络", tags: "备用,线路:CN2", price: 24, currency: "USD", expires_at: now + 46 * 86400, disk_total: 352 * 1024 ** 3, disk_used: 109.12 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 1.12 * 1024 ** 4, net_tx_total: 490 * 1024 ** 3, cpu: 45 }),
node(4, { id: "frankfurt-lab", name: "法兰克福 Lab", region: "DE", group_name: "实验服务", tags: "Lab,IPv6", price: -1, expires_at: null, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, cpu: 52, uptime: 134 * 86400 }),
node(5, { id: "taipei-homelab", name: "台北 HomeLab", region: "TW", group_name: "家庭网络", tags: "HomeLab,自建", price: -1, expires_at: null, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, cpu: 59, uptime: 122 * 86400 }),
node(6, { id: "london-archive", name: "伦敦 Archive", region: "GB", group_name: "存储服务", tags: "Archive,HDD", price: 18, currency: "EUR", billing_cycle: 365, expires_at: now + 110 * 86400, disk_total: 4 * 1024 ** 4, disk_used: 2.7 * 1024 ** 4, cpu: 66, latency: [] }),
node(7, { id: "toronto-standby", name: "多伦多 Standby", region: "CA", group_name: "备用节点", tags: "Standby", price: 16, currency: "CAD", expires_at: now + 18 * 86400, timestamp: now - 640, cpu: 0, net_in: 0, net_out: 0, message: "节点维护中" }),
node({ id: "hongkong-edge", name: "香港 Edge 01", region: "HK", group_name: "边缘网络", tags: "主力,线路:BGP,用途:网站", price: 128, expires_at: now + 46 * 86400, traffic_limit: 2 * 1024 ** 4 }),
node({ id: "tokyo-core", name: "东京 Core", region: "JP", group_name: "核心服务", tags: "主力,线路:Premium", price: 9.9, currency: "USD", expires_at: now + 46 * 86400, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, disk_total: 224 * 1024 ** 3, disk_used: 69.44 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 810 * 1024 ** 3, net_tx_total: 310 * 1024 ** 3, cpu: 31 }),
node({ id: "singapore-data", name: "新加坡 Data", region: "SG", group_name: "数据服务", tags: "数据库,NVMe", price: 88, expires_at: now + 46 * 86400, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, disk_total: 288 * 1024 ** 3, disk_used: 89.28 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 980 * 1024 ** 3, net_tx_total: 400 * 1024 ** 3, cpu: 38 }),
node({ id: "los-angeles-west", name: "洛杉矶 West", region: "US", group_name: "边缘网络", tags: "备用,线路:CN2", price: 24, currency: "USD", expires_at: now + 46 * 86400, disk_total: 352 * 1024 ** 3, disk_used: 109.12 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 1.12 * 1024 ** 4, net_tx_total: 490 * 1024 ** 3, cpu: 45 }),
node({ id: "frankfurt-lab", name: "法兰克福 Lab", region: "DE", group_name: "实验服务", tags: "Lab,IPv6", price: -1, expires_at: null, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, cpu: 52, uptime: 134 * 86400 }),
node({ id: "taipei-homelab", name: "台北 HomeLab", region: "TW", group_name: "家庭网络", tags: "HomeLab,自建", price: -1, expires_at: null, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, cpu: 59, uptime: 122 * 86400 }),
node({ id: "london-archive", name: "伦敦 Archive", region: "GB", group_name: "存储服务", tags: "Archive,HDD", price: 18, currency: "EUR", billing_cycle: 365, expires_at: now + 110 * 86400, disk_total: 4 * 1024 ** 4, disk_used: 2.7 * 1024 ** 4, cpu: 66, latency: [] }),
node({ id: "toronto-standby", name: "多伦多 Standby", region: "CA", group_name: "备用节点", tags: "Standby", price: 16, currency: "CAD", expires_at: now + 18 * 86400, timestamp: now - 640, cpu: 0, net_in: 0, net_out: 0, message: "节点维护中" }),
];
export function demoHistory(serverId: string, hours: number): HistoryPoint[] {
+29
View File
@@ -0,0 +1,29 @@
const CLIENT_PASSWORD_ROUNDS = 600_000;
const encoder = new TextEncoder();
function toHex(bytes: Uint8Array) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function derivePassword(password: string, deploymentSalt: string) {
if (!deploymentSalt) throw new Error("登录密码派生参数缺失");
if (!globalThis.crypto?.subtle) throw new Error("当前浏览器不支持安全密码派生");
const key = await globalThis.crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await globalThis.crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: "SHA-256",
iterations: CLIENT_PASSWORD_ROUNDS,
salt: encoder.encode(`nodeflare:${deploymentSalt}`),
},
key,
256,
);
return toHex(new Uint8Array(bits));
}
+28 -9
View File
@@ -333,11 +333,24 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
.usage-table tbody tr:last-child > * { border-bottom: 0; }
.usage-empty { display: flex; min-height: 48px; align-items: center; justify-content: center; border: 1px dashed var(--border); border-radius: 7px; color: var(--muted-foreground); font-size: 9px; }
.data-actions { border-top: 1px solid var(--border); padding-top: 14px; }
.builtin-theme-row { display: flex; min-height: 64px; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid var(--border); border-radius: 7px; padding: 11px 13px; }
.builtin-theme-row > div { display: grid; gap: 4px; }
.builtin-theme-row strong { font-size: 12px; }
.builtin-theme-row span { color: var(--muted-foreground); font-size: 9px; }
.builtin-theme-row b { border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); border-radius: 7px; padding: 3px 7px; color: var(--primary); font-size: 9px; }
.theme-store-page { display: grid; gap: 16px; }
.theme-store-grid { display: grid; gap: 9px; }
.theme-store-card { display: grid; min-width: 0; align-items: center; gap: 12px; border: 1px solid var(--border); border-radius: 7px; padding: 13px; grid-template-columns: 38px minmax(0, 1fr) auto; }
.theme-store-card.active { border-color: color-mix(in srgb, var(--primary) 55%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 18%, transparent); }
.theme-card-icon { display: inline-flex; width: 38px; height: 38px; align-items: center; justify-content: center; border-radius: 8px; color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); }
.theme-card-main { display: grid; min-width: 0; gap: 5px; }
.theme-card-title { display: flex; min-width: 0; align-items: center; gap: 7px; }
.theme-card-title strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.theme-card-title .theme-badge { border-radius: 999px; padding: 2px 6px; font-size: 9px; white-space: nowrap; }
.theme-badge.builtin { color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); }
.theme-badge.remote { color: var(--info); background: color-mix(in srgb, var(--info) 10%, transparent); }
.theme-card-main p, .theme-card-main small { margin: 0; overflow: hidden; color: var(--muted-foreground); font-size: 10px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
.theme-card-main a { display: inline-flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; color: var(--info); font-size: 10px; text-decoration: none; }
.theme-card-main a span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.theme-card-actions { display: flex; align-items: center; gap: 5px; }
.theme-active-btn { display: inline-flex; min-height: 30px; align-items: center; gap: 4px; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: 6px; padding: 0 9px; color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); font-size: 11px; font-weight: 600; }
.theme-risk-note { border-left: 3px solid var(--warning); padding: 7px 10px; color: var(--muted-foreground); background: color-mix(in srgb, var(--warning) 7%, transparent); font-size: 11px; line-height: 1.5; }
.theme-add-form code { border-radius: 3px; padding: 1px 3px; color: var(--foreground); background: color-mix(in srgb, var(--foreground) 8%, transparent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
.theme-option-grid { display: grid; align-items: start; gap: 12px; grid-template-columns: 1fr 1fr; }
.theme-option-empty { display: grid; min-height: 74px; place-items: center; border: 1px dashed var(--border); border-radius: 7px; color: var(--muted-foreground); font-size: 10px; }
.theme-color-option input { width: 100%; min-height: 40px; padding: 4px; cursor: pointer; }
@@ -450,6 +463,9 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
.server-usage { display: none; }
.server-row .row-actions { grid-column: 4 / 6; justify-self: end; }
.form-grid, .form-grid.three, .settings-toggles, .theme-option-grid { grid-template-columns: 1fr; }
.theme-store-card { grid-template-columns: 32px minmax(0, 1fr); }
.theme-card-icon { width: 32px; height: 32px; }
.theme-card-actions { grid-column: 2; justify-content: flex-start; }
.data-stat-grid { grid-template-columns: 1fr 1fr 1fr; }
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 8px; }
.editor-modal, .install-modal { padding: 15px 13px; }
@@ -564,10 +580,13 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
.usage-table th, .usage-table td { height: 40px; color: var(--admin-text); font-size: 12px; }
.usage-table thead th { color: var(--admin-muted); background: var(--admin-bg); }
.usage-empty { color: var(--admin-muted); font-size: 13px; }
.builtin-theme-row { border-color: var(--admin-border); }
.builtin-theme-row strong { color: var(--admin-text); font-size: 14px; }
.builtin-theme-row span { color: var(--admin-muted); font-size: 12px; }
.builtin-theme-row b { font-size: 11px; }
.admin-page .theme-store-card { border-color: var(--admin-border); background: var(--admin-surface); }
.admin-page .theme-store-card.active { border-color: var(--admin-accent-border); box-shadow: 0 0 0 1px color-mix(in srgb, var(--admin-accent) 18%, transparent); }
.admin-page .theme-card-title strong, .admin-page .theme-card-main small { color: var(--admin-text); }
.admin-page .theme-card-main p { color: var(--admin-muted); }
.admin-page .theme-card-main a { color: var(--admin-accent); }
.admin-page .theme-active-btn { border-color: var(--admin-accent-border); color: var(--admin-accent); background: var(--admin-active-bg); }
.admin-page .theme-risk-note { color: var(--admin-muted); }
.admin-page .theme-option-grid > .toggle-row { min-height: 40px; align-self: end; padding-block: 7px; }
.theme-option-empty { color: var(--admin-muted); font-size: 13px; }
.editor-modal, .install-modal, .latency-editor { border: 1px solid var(--admin-border); color: var(--admin-text); background: var(--admin-surface); }
+26 -21
View File
@@ -8,6 +8,7 @@ export interface Config {
offline_threshold_seconds: number;
history_retention_days: number;
default_theme: "system" | "light" | "dark";
active_theme_id: string;
background_url: string;
theme_options: Record<string, ThemeSettingValue>;
show_search: boolean;
@@ -23,6 +24,7 @@ export interface Config {
turnstile_enabled: boolean;
turnstile_login_enabled: boolean;
turnstile_site_key: string;
password_client_salt: string;
websocket: boolean;
}
@@ -56,6 +58,15 @@ export interface ThemeSettingsSchema {
settings: ThemeSettingField[];
}
export interface Theme {
id: string;
name: string;
description: string;
url: string;
builtin: boolean;
active: boolean;
}
export interface ExchangeRates {
base: "CNY";
rates: Record<string, number>;
@@ -113,10 +124,7 @@ export interface LatencyTask {
target: string;
interval_seconds: number;
default_enabled: boolean;
sort_order: number;
server_ids: string[];
created_at: number;
updated_at: number;
}
export type LatencyTaskInput = Pick<LatencyTask, "name" | "task_type" | "target" | "interval_seconds" | "default_enabled" | "server_ids">;
@@ -127,9 +135,6 @@ export interface Server {
region: string;
group_name: string;
tags: string;
note: string;
hidden: number;
sort_order: number;
expires_at: number | null;
traffic_limit: number;
traffic_limit_type: TrafficLimitType;
@@ -138,16 +143,7 @@ export interface Server {
currency: string;
auto_renewal: number;
public_remark: string;
network_interface: string;
reset_day: number;
report_interval: number;
collect_interval: number;
rx_correction: number;
tx_correction: number;
offline_notify_disabled: number;
auto_update: number;
created_at: number;
updated_at: number;
timestamp: number | null;
cpu: number | null;
load1: number | null;
@@ -173,8 +169,6 @@ export interface Server {
kernel: string | null;
arch: string | null;
virtualization: string | null;
ipv4: string | null;
ipv6: string | null;
gpu_usage: number | null;
gpu_model: string | null;
agent_version: string | null;
@@ -190,6 +184,18 @@ export interface Server {
latency?: LatencySample[];
}
export interface AdminServer extends Server {
note: string;
hidden: number;
network_interface: string;
report_interval: number;
collect_interval: number;
rx_correction: number;
tx_correction: number;
offline_notify_disabled: number;
auto_update: number;
}
export interface HistoryPoint {
timestamp: number;
cpu: number;
@@ -243,10 +249,11 @@ export interface ServerInput {
auto_update: boolean;
}
export interface Settings extends Omit<Config, "websocket"> {
export interface Settings extends Omit<Config, "websocket" | "password_client_salt"> {
admin_username: string;
admin_password_configured: boolean;
new_password?: string;
new_password_derived?: string;
turnstile_secret_key: string;
notification_enabled: boolean;
notification_endpoint: string;
@@ -269,11 +276,9 @@ export interface AlertRule {
aggregation: AlertAggregation;
enabled: number;
server_ids: string[];
created_at: number;
updated_at: number;
}
export interface AlertRuleInput extends Omit<AlertRule, "id" | "enabled" | "created_at" | "updated_at"> {
export interface AlertRuleInput extends Omit<AlertRule, "id" | "enabled"> {
enabled: boolean;
}
+14 -2
View File
@@ -63,8 +63,6 @@ CREATE TABLE IF NOT EXISTS latest_metrics (
kernel TEXT NOT NULL DEFAULT '',
arch TEXT NOT NULL DEFAULT '',
virtualization TEXT NOT NULL DEFAULT '',
ipv4 TEXT NOT NULL DEFAULT '',
ipv6 TEXT NOT NULL DEFAULT '',
gpu_usage REAL NOT NULL DEFAULT 0,
gpu_model TEXT NOT NULL DEFAULT '',
agent_version TEXT NOT NULL DEFAULT '',
@@ -152,7 +150,9 @@ INSERT OR IGNORE INTO settings(key, value, updated_at) VALUES
('favicon_url', '', unixepoch()),
('locale', 'zh-CN', unixepoch()),
('public_dashboard', 'true', unixepoch()),
('history_cache_version', '0', unixepoch()),
('default_theme', 'system', unixepoch()),
('active_theme_id', 'builtin-komari-glass', unixepoch()),
('background_url', '', unixepoch()),
('theme_options', '{}', unixepoch()),
('show_search', 'true', unixepoch()),
@@ -167,6 +167,7 @@ INSERT OR IGNORE INTO settings(key, value, updated_at) VALUES
('show_uptime', 'true', unixepoch()),
('admin_username', '', unixepoch()),
('admin_password_hash', '', unixepoch()),
('password_client_salt', lower(hex(randomblob(16))), unixepoch()),
('turnstile_enabled', 'false', unixepoch()),
('turnstile_login_enabled', 'true', unixepoch()),
('turnstile_site_key', '', unixepoch()),
@@ -188,6 +189,17 @@ CREATE TABLE IF NOT EXISTS exchange_rate_snapshots (
attempted_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_themes_created
ON themes(created_at DESC);
INSERT OR IGNORE INTO exchange_rate_snapshots (
base_currency, rates_json, source, rate_date, fetched_at, attempted_at
) VALUES (
+1 -1
View File
@@ -19,6 +19,6 @@
"test:smoke": "sh scripts/smoke-test.sh"
},
"devDependencies": {
"wrangler": "^4.120.0"
"wrangler": "^4.120.1"
}
}
+44 -8
View File
@@ -17,7 +17,18 @@ request() {
monitor_curl --fail --location --silent --show-error "$@"
}
request "$MONITOR_BASE_URL/api/config" | jq -e '.site_name | length > 0' >/dev/null
config_json=$(request "$MONITOR_BASE_URL/api/config")
printf '%s' "$config_json" | jq -e '.site_name | length > 0' >/dev/null
password_client_salt=$(printf '%s' "$config_json" | jq -er '.password_client_salt | select(length > 0)')
password_derived=$(NODEFLARE_PASSWORD="$MONITOR_ADMIN_PASSWORD" NODEFLARE_SALT="$password_client_salt" bun -e '
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey("raw", encoder.encode(process.env.NODEFLARE_PASSWORD), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", iterations: 600000, salt: encoder.encode(`nodeflare:${process.env.NODEFLARE_SALT}`) }, key, 256);
console.log(Array.from(new Uint8Array(bits), byte => byte.toString(16).padStart(2, "0")).join(""));
')
cross_origin_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H 'Origin: https://not-nodeflare.invalid' "$MONITOR_BASE_URL/api/config")
[ "$cross_origin_status" = "403" ]
admin_html=$(request "$MONITOR_BASE_URL/admin")
case "$admin_html" in
@@ -38,13 +49,28 @@ request "$MONITOR_BASE_URL/admin-assets/admin.js" >/dev/null
request "$MONITOR_BASE_URL/admin-assets/admin.css" >/dev/null
login_json=$(request -H 'Content-Type: application/json' \
--data "$(jq -nc --arg username "$MONITOR_ADMIN_USERNAME" --arg password "$MONITOR_ADMIN_PASSWORD" --arg turnstile_token "$MONITOR_TURNSTILE_TOKEN" '{username:$username,password:$password,turnstile_token:$turnstile_token}')" \
--data "$(jq -nc --arg username "$MONITOR_ADMIN_USERNAME" --arg password "$MONITOR_ADMIN_PASSWORD" --arg password_derived "$password_derived" --arg turnstile_token "$MONITOR_TURNSTILE_TOKEN" '{username:$username,password:$password,password_derived:$password_derived,turnstile_token:$turnstile_token}')" \
"$MONITOR_BASE_URL/api/admin/login")
admin_token=$(printf '%s' "$login_json" | jq -er '.token')
incomplete_server_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
--data '{"name":"incomplete"}' "$MONITOR_BASE_URL/api/admin/servers")
[ "$incomplete_server_status" = "400" ]
unknown_setting_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-X PATCH -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
--data '{"unexpected":true}' "$MONITOR_BASE_URL/api/admin/settings")
[ "$unknown_setting_status" = "400" ]
request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/exchange-rates" | \
jq -e '.base == "CNY" and .rates.CNY == 1 and .rates.USD > 0 and .rates.CAD > 0 and .cny.USD == .rates.USD' >/dev/null
jq -e '.base == "CNY" and .rates.CNY == 1 and .rates.USD > 0 and .rates.CAD > 0 and (has("cny") | not)' >/dev/null
request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/themes" | \
jq -e '.themes | any(.builtin == true and .id == "builtin-komari-glass" and .active == true)' >/dev/null
server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","note":"private","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true,"public_remark":"public","network_interface":"","reset_day":1,"report_interval":60,"collect_interval":5,"rx_correction":0,"tx_correction":0,"offline_notify_disabled":false,"auto_update":true}'
server_id=
latency_task_id=
@@ -73,15 +99,21 @@ latency_task_id=$(printf '%s' "$latency_task_json" | jq -er '.id')
server_json=$(request -H "Authorization: Bearer $admin_token" \
-H 'Content-Type: application/json' \
--data '{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","note":"private","public_remark":"public","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true}' \
--data "$server_input" \
"$MONITOR_BASE_URL/api/admin/servers")
server_id=$(printf '%s' "$server_json" | jq -er '.id')
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
agent_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' agent/Cargo.toml | head -1)
incomplete_report_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg server_id "$server_id" '{server_id:$server_id,samples:[{timestamp:(now|floor),cpu:1}]}')" \
"$MONITOR_BASE_URL/api/agent/report")
[ "$incomplete_report_status" = "400" ]
sample=$(jq -nc --arg agent_version "$agent_version" '{
timestamp:(now|floor),cpu:18.5,load1:0.42,load5:0.36,load15:0.31,
mem_used:2147483648,mem_total:4294967296,disk_used:21474836480,disk_total:53687091200,
mem_used:2147483648,mem_total:4294967296,swap_used:0,swap_total:0,disk_used:21474836480,disk_total:53687091200,
net_in:4096,net_out:2048,net_rx_total:1073741824,net_tx_total:536870912,
uptime:86400,processes:90,tcp_connections:18,udp_connections:4,cpu_cores:2,
cpu_model:"Smoke CPU",os:"Debian 12",kernel:"6.1",arch:"x86_64",virtualization:"kvm",
@@ -89,7 +121,7 @@ sample=$(jq -nc --arg agent_version "$agent_version" '{
disk_read_iops:120,disk_write_iops:48,disk_await_ms:1.4,disk_utilization:8.2,
disks:[{name:"/dev/vda1",mount_point:"/",used:21474836480,total:53687091200,read_bps:4194304,write_bps:2097152,read_iops:120,write_iops:48,await_ms:1.4,utilization:8.2}],
gpus:[{model:"NVIDIA T4",usage:32.5,memory_used:1073741824,memory_total:17179869184}],
agent_version:$agent_version,message:"smoke"
agent_version:$agent_version,message:"smoke",latency_results:[]
}')
report=$(jq -nc --arg server_id "$server_id" --argjson sample "$sample" '{server_id:$server_id,samples:[$sample]}')
report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
@@ -102,8 +134,12 @@ latency_report=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" '.s
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.success == true' >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/servers/$server_id" | jq -e --arg task_id "$latency_task_id" '.cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .disk_used == 21474836480 and .traffic_limit == 107374182400 and .price == 9.9 and .note == "" and .public_remark == "public" and (.latency | any(.task_id == $task_id and .latency_ms == 28.4 and .packet_loss == 25))' >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/servers/$server_id" | jq -e --arg task_id "$latency_task_id" '.cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .disk_used == 21474836480 and .traffic_limit == 107374182400 and .price == 9.9 and (has("note") | not) and .public_remark == "public" and (.latency | any(.task_id == $task_id and .latency_ms == 28.4 and .packet_loss == 25))' >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | jq -e '.points | length >= 1 and any(.gpu_usage == 32.5)' >/dev/null
history_cache_header=$(monitor_curl --silent --dump-header - --output /dev/null \
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | \
tr -d '\r' | awk -F ': ' 'tolower($1) == "x-cache" { print $2 }' | tail -1)
[ "$history_cache_header" = "HIT" ]
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | jq -e --arg task_id "$latency_task_id" '(.tasks | any(.id == $task_id)) and (.points | any(.task_id == $task_id and .latency_ms == 28.4))' >/dev/null
alert_rule_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke CPU",metric:"cpu",threshold:80,duration_minutes:5,aggregation:"average",enabled:true,server_ids:[$server_id]}')" \
@@ -122,7 +158,7 @@ request -H "Authorization: Bearer $rotated_token" -H 'Content-Type: application/
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.success == true' >/dev/null
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
--data '{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","note":"private","public_remark":"public","hidden":true,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true}' \
--data "$(printf '%s' "$server_input" | jq '.hidden=true')" \
"$MONITOR_BASE_URL/api/admin/servers/$server_id" | jq -e '.success == true' >/dev/null
hidden_detail_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer $admin_token" \
+110 -18
View File
@@ -1,12 +1,18 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, Mac};
use hmac::{Hmac, KeyInit, Mac};
use pbkdf2::pbkdf2_hmac;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use worker::{Date, Env, Request};
use worker::{
js_sys::{Function, Reflect, Uint8Array},
wasm_bindgen::{JsCast, JsValue},
Date, Env, Request,
};
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60;
const PASSWORD_ROUNDS: usize = 10_000;
const THEME_PREVIEW_SECONDS: i64 = 10 * 60;
const SERVER_PASSWORD_ROUNDS: u32 = 10_000;
const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
type HmacSha256 = Hmac<Sha256>;
@@ -47,36 +53,63 @@ pub fn admin_username(env: &Env, configured: &str) -> String {
.unwrap_or_else(|_| "admin".to_string())
}
fn password_digest(password: &str, salt: &str) -> String {
let mut digest = Sha256::digest(format!("nodeflare:{salt}:{password}").as_bytes()).to_vec();
for _ in 1..PASSWORD_ROUNDS {
let mut hasher = Sha256::new();
hasher.update(&digest);
hasher.update(salt.as_bytes());
digest = hasher.finalize().to_vec();
}
fn password_digest(password_derived: &str, salt: &str, rounds: u32) -> String {
let mut digest = [0_u8; 32];
pbkdf2_hmac::<Sha256>(
password_derived.as_bytes(),
salt.as_bytes(),
rounds,
&mut digest,
);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub fn hash_password(password: &str, salt: &str) -> String {
format!("sha256${salt}${}", password_digest(password, salt))
pub fn hash_password(password_derived: &str, salt: &str) -> String {
format!(
"pbkdf2_sha256_client_v1${SERVER_PASSWORD_ROUNDS}${salt}${}",
password_digest(password_derived, salt, SERVER_PASSWORD_ROUNDS)
)
}
fn verify_password_hash(password: &str, encoded: &str) -> bool {
pub fn random_salt() -> Option<String> {
let global = worker::js_sys::global();
let crypto = Reflect::get(&global, &JsValue::from_str("crypto")).ok()?;
let get_random_values = Reflect::get(&crypto, &JsValue::from_str("getRandomValues"))
.ok()?
.dyn_into::<Function>()
.ok()?;
let salt = Uint8Array::new_with_length(16);
get_random_values.call1(&crypto, &salt).ok()?;
Some(
salt.to_vec()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect(),
)
}
fn verify_password_hash(password_derived: &str, encoded: &str) -> bool {
let mut parts = encoded.split('$');
let Some(algorithm) = parts.next() else {
return false;
};
let Some(rounds) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
return false;
};
let Some(salt) = parts.next() else {
return false;
};
let Some(expected) = parts.next() else {
return false;
};
if algorithm != "sha256" || salt.is_empty() || parts.next().is_some() {
if algorithm != "pbkdf2_sha256_client_v1"
|| rounds != SERVER_PASSWORD_ROUNDS
|| salt.len() < 32
|| parts.next().is_some()
{
return false;
}
secure_eq(&password_digest(password, salt), expected)
secure_eq(&password_digest(password_derived, salt, rounds), expected)
}
pub fn verify_credentials(
@@ -85,6 +118,7 @@ pub fn verify_credentials(
password_hash: &str,
username: &str,
password: &str,
password_derived: &str,
) -> bool {
let expected_username = admin_username(env, configured_username);
let username_valid = secure_eq(&expected_username, username.trim());
@@ -93,7 +127,7 @@ pub fn verify_credentials(
.map(|expected| secure_eq(&expected, password))
.unwrap_or(false)
} else {
verify_password_hash(password, password_hash)
verify_password_hash(password_derived, password_hash)
};
username_valid && password_valid
}
@@ -198,6 +232,43 @@ pub fn verify_turnstile_proof(value: &str, env: &Env, password_hash: &str) -> bo
secure_eq(&expected, signature)
}
pub fn create_theme_preview_proof(
env: &Env,
password_hash: &str,
theme_id: &str,
) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
let expires = Date::now().as_millis() as i64 / 1000 + THEME_PREVIEW_SECONDS;
let payload = format!("{theme_id}.{expires}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(format!("nodeflare-theme-preview:{payload}").as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
Some(format!("{payload}.{signature}"))
}
pub fn verify_theme_preview_proof(value: &str, env: &Env, password_hash: &str) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
let mut parts = value.split('.');
let (Some(theme_id), Some(expires), Some(signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return None;
};
if theme_id.is_empty() || theme_id.len() > 80 || theme_id.contains('/') {
return None;
}
let expires = expires.parse::<i64>().ok()?;
let current = Date::now().as_millis() as i64 / 1000;
if expires < current || expires > current + THEME_PREVIEW_SECONDS + 60 {
return None;
}
let signature = URL_SAFE_NO_PAD.decode(signature).ok()?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(format!("nodeflare-theme-preview:{theme_id}.{expires}").as_bytes());
mac.verify_slice(&signature).ok()?;
Some(theme_id.to_string())
}
pub fn bearer_token(req: &Request) -> Option<String> {
req.headers()
.get("Authorization")
@@ -208,7 +279,28 @@ pub fn bearer_token(req: &Request) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{create_admin_jwt_with_secret, verify_admin_jwt, SESSION_SECONDS};
use super::{
create_admin_jwt_with_secret, hash_password, verify_admin_jwt, verify_password_hash,
SESSION_SECONDS,
};
#[test]
fn hashes_passwords_with_pbkdf2() {
let encoded = hash_password(
"correct horse battery staple",
"0123456789abcdef0123456789abcdef",
);
assert!(encoded.starts_with("pbkdf2_sha256_client_v1$10000$"));
assert!(verify_password_hash(
"correct horse battery staple",
&encoded
));
assert!(!verify_password_hash("wrong", &encoded));
assert!(!verify_password_hash(
"correct horse battery staple",
"sha256$0123456789abcdef0123456789abcdef$invalid"
));
}
#[test]
fn signs_and_verifies_admin_jwt() {
+262 -39
View File
@@ -1,28 +1,28 @@
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize, Serializer};
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
use crate::models::{
AgentConfigView, AgentReport, AlertRuleInput, AlertRuleView, HistoryPoint, ServerInput,
ServerView, SettingsInput, TokenHashRow,
ServerView, SettingsInput, ThemeInput, ThemeView, TokenHashRow,
};
const SERVER_SELECT: &str = r#"
SELECT
s.id, s.name, s.region, s.group_name, s.tags, s.note, s.hidden,
s.sort_order, s.expires_at, s.traffic_limit, s.traffic_limit_type,
s.expires_at, s.traffic_limit, s.traffic_limit_type,
s.price, s.billing_cycle, s.currency, s.auto_renewal, s.public_remark,
s.network_interface, s.reset_day, s.report_interval, s.collect_interval,
s.rx_correction, s.tx_correction, s.offline_notify_disabled, s.auto_update,
s.created_at, s.updated_at,
s.created_at,
m.timestamp, m.cpu, m.load1, m.load5, m.load15, m.mem_used, m.mem_total,
m.swap_used, m.swap_total, m.disk_used, m.disk_total, m.net_in, m.net_out,
CASE WHEN m.net_rx_total IS NULL THEN NULL ELSE m.net_rx_total + s.rx_correction END AS net_rx_total,
CASE WHEN m.net_tx_total IS NULL THEN NULL ELSE m.net_tx_total + s.tx_correction END AS net_tx_total,
m.uptime, m.processes, m.tcp_connections,
m.udp_connections, m.cpu_cores, m.cpu_model, m.os, m.kernel, m.arch,
m.virtualization, m.ipv4, m.ipv6, m.gpu_usage, m.gpu_model, m.agent_version,
m.virtualization, m.gpu_usage, m.gpu_model, m.agent_version,
m.disk_read_bps, m.disk_write_bps, m.disk_read_iops, m.disk_write_iops,
m.disk_await_ms, m.disk_utilization, m.disk_info, m.gpu_info,
m.message
@@ -30,12 +30,45 @@ FROM servers s
LEFT JOIN latest_metrics m ON m.server_id = s.id
"#;
pub const SECRET_MASK: &str = "********";
fn secret_for_api(value: &str) -> &str {
if value.is_empty() {
""
} else {
SECRET_MASK
}
}
fn serialize_secret<S>(value: &str, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(secret_for_api(value))
}
#[derive(Debug, serde::Deserialize)]
struct SettingRow {
key: String,
value: String,
}
#[derive(Debug, Deserialize)]
struct ThemeRow {
id: String,
name: String,
description: String,
url: String,
}
#[derive(Debug, Deserialize)]
struct AgentConfigRow {
report_interval: i64,
collect_interval: i64,
network_interface: String,
auto_update: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SettingsView {
pub site_name: String,
@@ -46,7 +79,10 @@ pub struct SettingsView {
pub public_dashboard: bool,
pub offline_threshold_seconds: i64,
pub history_retention_days: i64,
#[serde(skip_serializing)]
pub history_cache_version: i64,
pub default_theme: String,
pub active_theme_id: String,
pub background_url: String,
pub theme_options: serde_json::Value,
pub show_search: bool,
@@ -63,16 +99,21 @@ pub struct SettingsView {
pub admin_password_configured: bool,
#[serde(skip_serializing)]
pub admin_password_hash: String,
#[serde(skip_serializing)]
pub password_client_salt: String,
pub turnstile_enabled: bool,
pub turnstile_login_enabled: bool,
pub turnstile_site_key: String,
#[serde(serialize_with = "serialize_secret")]
pub turnstile_secret_key: String,
pub notification_enabled: bool,
#[serde(serialize_with = "serialize_secret")]
pub notification_endpoint: String,
pub notification_target: String,
pub offline_alert_minutes: i64,
pub expiry_alert_days: i64,
pub cloudflare_account_id: String,
#[serde(serialize_with = "serialize_secret")]
pub cloudflare_api_token: String,
}
@@ -104,8 +145,6 @@ struct AlertRuleRow {
duration_minutes: i64,
aggregation: String,
enabled: i64,
created_at: i64,
updated_at: i64,
}
#[derive(Debug, Deserialize)]
@@ -118,6 +157,10 @@ pub struct AlertMetricRow {
pub server_id: String,
pub name: String,
pub value: f64,
pub sample_count: i64,
pub first_timestamp: i64,
pub last_timestamp: i64,
pub report_interval: i64,
}
#[derive(Debug, Deserialize)]
@@ -170,7 +213,7 @@ pub async fn get_token_hash(db: &D1Database, id: &str) -> Result<Option<TokenHas
}
pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfigView>> {
let mut config: Option<AgentConfigView> = db
let row: Option<AgentConfigRow> = db
.prepare(
"SELECT report_interval, collect_interval, network_interface, auto_update \
FROM servers WHERE id = ?1",
@@ -178,11 +221,16 @@ pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfi
.bind(&[text(id)])?
.first(None)
.await?;
if let Some(config) = config.as_mut() {
config.network_interface = config.network_interface.trim().to_string();
config.latency_tasks = crate::latency::tasks_for_server(db, id).await?;
}
Ok(config)
let Some(row) = row else {
return Ok(None);
};
Ok(Some(AgentConfigView {
report_interval: row.report_interval,
collect_interval: row.collect_interval,
network_interface: row.network_interface.trim().to_string(),
auto_update: row.auto_update,
latency_tasks: crate::latency::tasks_for_server(db, id).await?,
}))
}
pub async fn create_server(
@@ -329,8 +377,8 @@ pub async fn reorder_servers(db: &D1Database, ids: &[String]) -> Result<()> {
pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
let rows: Vec<AlertRuleRow> = db
.prepare(
"SELECT id, name, metric, threshold, duration_minutes, aggregation, enabled, \
created_at, updated_at FROM alert_rules ORDER BY created_at ASC",
"SELECT id, name, metric, threshold, duration_minutes, aggregation, enabled \
FROM alert_rules ORDER BY created_at ASC",
)
.all()
.await?
@@ -355,8 +403,6 @@ pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
aggregation: row.aggregation,
enabled: row.enabled,
server_ids: servers.into_iter().map(|row| row.server_id).collect(),
created_at: row.created_at,
updated_at: row.updated_at,
});
}
Ok(rules)
@@ -453,20 +499,41 @@ pub async fn alert_metric_values(
"AVG"
};
let since = current_time - rule.duration_minutes.clamp(1, 1440) * 60;
let minimum_samples = rule.duration_minutes.clamp(1, 1440);
let query = format!(
"SELECT s.id AS server_id, s.name, {aggregate}({metric}) AS value \
"SELECT s.id AS server_id, s.name, {aggregate}({metric}) AS value, \
COUNT(*) AS sample_count, MIN(h.timestamp) AS first_timestamp, \
MAX(h.timestamp) AS last_timestamp, s.report_interval \
FROM servers s JOIN metric_history h ON h.server_id=s.id \
WHERE h.timestamp >= ?1 AND s.hidden=0 AND (\
NOT EXISTS(SELECT 1 FROM alert_rule_servers ars WHERE ars.rule_id=?2) OR \
EXISTS(SELECT 1 FROM alert_rule_servers ars WHERE ars.rule_id=?2 AND ars.server_id=s.id)\
) GROUP BY s.id, s.name HAVING COUNT(*) >= ?3"
) GROUP BY s.id, s.name, s.report_interval"
);
db.prepare(query)
.bind(&[number(since), text(&rule.id), number(minimum_samples)])?
let rows: Vec<AlertMetricRow> = db
.prepare(query)
.bind(&[number(since), text(&rule.id)])?
.all()
.await?
.results()
.results()?;
Ok(rows
.into_iter()
.filter(|row| alert_window_covered(row, rule.duration_minutes, current_time))
.collect())
}
fn alert_window_covered(row: &AlertMetricRow, duration_minutes: i64, current_time: i64) -> bool {
let window_seconds = duration_minutes.clamp(1, 1440) * 60;
let report_interval = row.report_interval.clamp(15, 3600).max(60);
let expected_samples = ((window_seconds + report_interval - 1) / report_interval).max(1);
let required_samples = if expected_samples == 1 {
1
} else {
((expected_samples * 3 + 4) / 5).max(2)
};
let fresh_within = (report_interval * 2).max(120);
row.sample_count >= required_samples
&& row.first_timestamp <= row.last_timestamp
&& current_time.saturating_sub(row.last_timestamp) <= fresh_within
}
pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::HashSet<String>> {
@@ -481,16 +548,25 @@ pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::Ha
.collect())
}
pub async fn replace_active_alert_states(
pub async fn sync_active_alert_states(
db: &D1Database,
keys: &std::collections::HashSet<String>,
previous: &std::collections::HashSet<String>,
current: &std::collections::HashSet<String>,
) -> Result<()> {
let mut statements = vec![db.prepare("DELETE FROM alert_states")];
let mut statements = Vec::new();
let delete = db.prepare("DELETE FROM alert_states WHERE rule_id=?1 AND server_id=?2");
let insert = db.prepare(
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3)",
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3) \
ON CONFLICT(rule_id, server_id) DO UPDATE SET active=1, updated_at=excluded.updated_at",
);
let timestamp = now();
for key in keys {
for key in previous.difference(current) {
let Some((rule_id, server_id)) = key.split_once(':') else {
continue;
};
statements.push(delete.clone().bind(&[text(rule_id), text(server_id)])?);
}
for key in current.difference(previous) {
let Some((rule_id, server_id)) = key.split_once(':') else {
continue;
};
@@ -500,7 +576,9 @@ pub async fn replace_active_alert_states(
number(timestamp),
])?);
}
db.batch(statements).await?;
if !statements.is_empty() {
db.batch(statements).await?;
}
Ok(())
}
@@ -568,14 +646,13 @@ pub async fn save_reports(db: &D1Database, server_id: &str, reports: &[AgentRepo
swap_used, swap_total, disk_used, disk_total, net_in, net_out,
net_rx_total, net_tx_total, uptime, processes, tcp_connections,
udp_connections, cpu_cores, cpu_model, os, kernel, arch,
virtualization, ipv4, ipv6, gpu_usage, gpu_model, agent_version,
virtualization, gpu_usage, gpu_model, agent_version,
disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops,
disk_await_ms, disk_utilization, disk_info, gpu_info, message
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26,
?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38,
?39, ?40
?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38
) ON CONFLICT(server_id) DO UPDATE SET
timestamp=excluded.timestamp, cpu=excluded.cpu, load1=excluded.load1,
load5=excluded.load5, load15=excluded.load15, mem_used=excluded.mem_used,
@@ -587,8 +664,7 @@ pub async fn save_reports(db: &D1Database, server_id: &str, reports: &[AgentRepo
tcp_connections=excluded.tcp_connections, udp_connections=excluded.udp_connections,
cpu_cores=excluded.cpu_cores, cpu_model=excluded.cpu_model, os=excluded.os,
kernel=excluded.kernel, arch=excluded.arch,
virtualization=excluded.virtualization, ipv4=excluded.ipv4, ipv6=excluded.ipv6,
gpu_usage=excluded.gpu_usage, gpu_model=excluded.gpu_model,
virtualization=excluded.virtualization, gpu_usage=excluded.gpu_usage, gpu_model=excluded.gpu_model,
agent_version=excluded.agent_version,
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,
@@ -676,8 +752,6 @@ fn report_values(server_id: &str, report: &AgentReport, timestamp: i64) -> Vec<J
text(&report.kernel),
text(&report.arch),
text(&report.virtualization),
text(&report.ipv4),
text(&report.ipv6),
number(report.gpu_usage),
text(&report.gpu_model),
text(&report.agent_version),
@@ -778,7 +852,9 @@ pub async fn settings(
"history_retention_days",
default_retention.clamp(1, 365),
),
history_cache_version: integer_setting(&values, "history_cache_version", 0),
default_theme: string_setting(&values, "default_theme", "system"),
active_theme_id: string_setting(&values, "active_theme_id", crate::theme::BUILTIN_THEME_ID),
background_url: string_setting(&values, "background_url", ""),
theme_options: values
.get("theme_options")
@@ -800,6 +876,7 @@ pub async fn settings(
.get("admin_password_hash")
.is_some_and(|value| !value.is_empty()),
admin_password_hash: string_setting(&values, "admin_password_hash", ""),
password_client_salt: string_setting(&values, "password_client_salt", ""),
turnstile_enabled: bool_setting(&values, "turnstile_enabled", false),
turnstile_login_enabled: bool_setting(&values, "turnstile_login_enabled", true),
turnstile_site_key: string_setting(&values, "turnstile_site_key", ""),
@@ -863,6 +940,9 @@ pub async fn update_settings(
if let Some(value) = input.default_theme.as_deref() {
push_setting!("default_theme", value);
}
if let Some(value) = input.active_theme_id.as_deref() {
push_setting!("active_theme_id", value.trim());
}
if let Some(value) = input.background_url.as_deref() {
push_setting!("background_url", value.trim());
}
@@ -918,13 +998,21 @@ pub async fn update_settings(
if let Some(value) = input.turnstile_site_key.as_deref() {
push_setting!("turnstile_site_key", value.trim());
}
if let Some(value) = input.turnstile_secret_key.as_deref() {
if let Some(value) = input
.turnstile_secret_key
.as_deref()
.filter(|value| value.trim() != SECRET_MASK)
{
push_setting!("turnstile_secret_key", value.trim());
}
if let Some(value) = input.notification_enabled {
push_setting!("notification_enabled", if value { "true" } else { "false" });
}
if let Some(value) = input.notification_endpoint.as_deref() {
if let Some(value) = input
.notification_endpoint
.as_deref()
.filter(|value| value.trim() != SECRET_MASK)
{
push_setting!("notification_endpoint", value.trim());
}
if let Some(value) = input.notification_target.as_deref() {
@@ -946,13 +1034,104 @@ pub async fn update_settings(
};
}
push_trimmed!(input.cloudflare_account_id, "cloudflare_account_id");
push_trimmed!(input.cloudflare_api_token, "cloudflare_api_token");
if let Some(value) = input
.cloudflare_api_token
.as_deref()
.filter(|value| value.trim() != SECRET_MASK)
{
push_setting!("cloudflare_api_token", value.trim());
}
if !statements.is_empty() {
db.batch(statements).await?;
}
Ok(())
}
pub async fn list_themes(db: &D1Database, active_id: &str) -> Result<Vec<ThemeView>> {
let rows: Vec<ThemeRow> = db
.prepare("SELECT id, name, description, url FROM themes ORDER BY created_at DESC")
.all()
.await?
.results()?;
Ok(rows
.into_iter()
.map(|row| {
let active = row.id == active_id;
ThemeView {
id: row.id,
name: row.name,
description: row.description,
url: row.url,
builtin: false,
active,
}
})
.collect())
}
pub async fn create_theme(
db: &D1Database,
id: &str,
input: &ThemeInput,
source_url: &str,
timestamp: i64,
) -> Result<()> {
db.prepare(
"INSERT INTO themes(id, name, description, url, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(&[
text(id),
text(input.name.trim()),
text(input.description.trim()),
text(source_url),
number(timestamp),
])?
.run()
.await?;
Ok(())
}
pub async fn theme_exists(db: &D1Database, id: &str) -> Result<bool> {
let row: Option<ThemeRow> = db
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
.bind(&[text(id)])?
.first(None)
.await?;
Ok(row.is_some())
}
pub async fn theme_url(db: &D1Database, id: &str) -> Result<Option<String>> {
let row: Option<ThemeRow> = db
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
.bind(&[text(id)])?
.first(None)
.await?;
row.map(|theme| crate::theme::resolve_url(&theme.url).map(|resolved| resolved.resolved_url))
.transpose()
}
pub async fn set_active_theme(db: &D1Database, id: &str) -> Result<bool> {
if id != crate::theme::BUILTIN_THEME_ID && !theme_exists(db, id).await? {
return Ok(false);
}
save_setting(db, "active_theme_id", id).await?;
Ok(true)
}
pub async fn delete_theme(db: &D1Database, id: &str) -> Result<bool> {
let result = db
.prepare("DELETE FROM themes WHERE id = ?1")
.bind(&[text(id)])?
.run()
.await?;
let deleted = result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) > 0;
if deleted && get_setting(db, "active_theme_id").await?.as_deref() == Some(id) {
save_setting(db, "active_theme_id", crate::theme::BUILTIN_THEME_ID).await?;
}
Ok(deleted)
}
pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()> {
let cutoff = now() - retention_days.clamp(1, 365) * 86_400;
let recent_cutoff = now() - 7 * 86_400;
@@ -1120,3 +1299,47 @@ pub async fn update_expiry(db: &D1Database, id: &str, expires_at: i64) -> Result
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{alert_window_covered, secret_for_api, AlertMetricRow, SECRET_MASK};
fn row(samples: i64, last_timestamp: i64, report_interval: i64) -> AlertMetricRow {
AlertMetricRow {
server_id: "server".to_string(),
name: "Server".to_string(),
value: 90.0,
sample_count: samples,
first_timestamp: last_timestamp - samples.saturating_sub(1) * 60,
last_timestamp,
report_interval,
}
}
#[test]
fn alert_coverage_tracks_report_interval_and_freshness() {
let current = 10_000;
assert!(alert_window_covered(&row(6, current - 30, 60), 10, current));
assert!(!alert_window_covered(
&row(5, current - 30, 60),
10,
current
));
assert!(alert_window_covered(
&row(2, current - 60, 300),
10,
current
));
assert!(!alert_window_covered(
&row(2, current - 700, 300),
10,
current
));
}
#[test]
fn masks_stored_secrets_in_api_responses() {
assert_eq!(secret_for_api(""), "");
assert_eq!(secret_for_api("stored-secret"), SECRET_MASK);
}
}
+4 -13
View File
@@ -13,9 +13,6 @@ struct TaskRow {
target: String,
interval_seconds: i64,
default_enabled: i64,
sort_order: i64,
created_at: i64,
updated_at: i64,
}
#[derive(Debug, Deserialize)]
@@ -32,10 +29,7 @@ pub struct LatencyTaskView {
pub target: String,
pub interval_seconds: i64,
pub default_enabled: bool,
pub sort_order: i64,
pub server_ids: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -61,8 +55,8 @@ fn number(value: impl ToString) -> JsValue {
pub async fn list_tasks(db: &D1Database) -> Result<Vec<LatencyTaskView>> {
let rows: Vec<TaskRow> = db
.prepare(
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
sort_order, created_at, updated_at FROM latency_tasks \
"SELECT id, name, task_type, target, interval_seconds, default_enabled \
FROM latency_tasks \
ORDER BY sort_order ASC, created_at ASC",
)
.all()
@@ -93,9 +87,6 @@ pub async fn list_tasks(db: &D1Database) -> Result<Vec<LatencyTaskView>> {
target: row.target,
interval_seconds: row.interval_seconds,
default_enabled: row.default_enabled != 0,
sort_order: row.sort_order,
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect())
}
@@ -153,8 +144,8 @@ pub async fn update_task(
) -> Result<bool> {
let exists: Option<TaskRow> = db
.prepare(
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
sort_order, created_at, updated_at FROM latency_tasks WHERE id = ?1",
"SELECT id, name, task_type, target, interval_seconds, default_enabled \
FROM latency_tasks WHERE id = ?1",
)
.bind(&[text(id)])?
.first(None)
+945 -194
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -7,6 +7,9 @@ pub struct LiveHub {
impl DurableObject for LiveHub {
fn new(state: State, _env: Env) -> Self {
if let Ok(pair) = WebSocketRequestResponsePair::new("ping", "pong") {
state.set_websocket_auto_response(&pair);
}
Self { state }
}
@@ -51,12 +54,9 @@ impl DurableObject for LiveHub {
async fn websocket_message(
&self,
ws: WebSocket,
message: WebSocketIncomingMessage,
_ws: WebSocket,
_message: WebSocketIncomingMessage,
) -> Result<()> {
if matches!(message, WebSocketIncomingMessage::String(ref value) if value == "ping") {
ws.send_with_str("pong")?;
}
Ok(())
}
+45 -120
View File
@@ -1,102 +1,49 @@
use serde::{Deserialize, Serialize};
fn default_group() -> String {
"默认".to_string()
}
fn default_price() -> f64 {
0.0
}
fn default_enabled() -> bool {
true
}
fn default_billing_cycle() -> i64 {
30
}
fn default_currency() -> String {
"CNY".to_string()
}
fn default_traffic_limit_type() -> String {
"sum".to_string()
}
fn default_reset_day() -> i64 {
1
}
fn default_report_interval() -> i64 {
60
}
fn default_collect_interval() -> i64 {
5
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LoginRequest {
pub username: String,
pub password: String,
#[serde(default)]
pub password_derived: String,
pub turnstile_token: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TurnstileVerifyRequest {
pub token: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerInput {
pub name: String,
#[serde(default)]
pub region: String,
#[serde(default = "default_group")]
pub group_name: String,
#[serde(default)]
pub tags: String,
#[serde(default)]
pub note: String,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub expires_at: Option<i64>,
#[serde(default)]
pub traffic_limit: i64,
#[serde(default = "default_traffic_limit_type")]
pub traffic_limit_type: String,
#[serde(default = "default_price")]
pub price: f64,
#[serde(default = "default_billing_cycle")]
pub billing_cycle: i64,
#[serde(default = "default_currency")]
pub currency: String,
#[serde(default)]
pub auto_renewal: bool,
#[serde(default)]
pub public_remark: String,
#[serde(default)]
pub network_interface: String,
#[serde(default = "default_reset_day")]
pub reset_day: i64,
#[serde(default = "default_report_interval")]
pub report_interval: i64,
#[serde(default = "default_collect_interval")]
pub collect_interval: i64,
#[serde(default)]
pub rx_correction: i64,
#[serde(default)]
pub tx_correction: i64,
#[serde(default)]
pub offline_notify_disabled: bool,
#[serde(default = "default_enabled")]
pub auto_update: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SettingsInput {
pub site_name: Option<String>,
pub site_description: Option<String>,
@@ -107,6 +54,7 @@ pub struct SettingsInput {
pub offline_threshold_seconds: Option<i64>,
pub history_retention_days: Option<i64>,
pub default_theme: Option<String>,
pub active_theme_id: Option<String>,
pub background_url: Option<String>,
pub theme_options: Option<serde_json::Value>,
pub show_search: Option<bool>,
@@ -121,6 +69,7 @@ pub struct SettingsInput {
pub show_uptime: Option<bool>,
pub admin_username: Option<String>,
pub new_password: Option<String>,
pub new_password_derived: Option<String>,
pub turnstile_enabled: Option<bool>,
pub turnstile_login_enabled: Option<bool>,
pub turnstile_site_key: Option<String>,
@@ -135,24 +84,43 @@ pub struct SettingsInput {
}
#[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 builtin: bool,
pub active: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerOrderInput {
pub ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerBatchInput {
pub ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LatencyTaskInput {
pub name: String,
pub task_type: String,
pub target: String,
pub interval_seconds: i64,
#[serde(default)]
pub default_enabled: bool,
#[serde(default)]
pub server_ids: Vec<String>,
}
@@ -166,6 +134,7 @@ pub struct AgentLatencyTask {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentLatencyResult {
pub task_id: String,
pub timestamp: i64,
@@ -173,7 +142,8 @@ pub struct AgentLatencyResult {
pub packet_loss: f64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentDiskMetric {
pub name: String,
pub mount_point: String,
@@ -187,7 +157,8 @@ pub struct AgentDiskMetric {
pub utilization: f64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentGpuMetric {
pub model: String,
pub usage: f64,
@@ -195,93 +166,53 @@ pub struct AgentGpuMetric {
pub memory_total: i64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentReportBatch {
pub server_id: String,
pub samples: Vec<AgentReport>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentReport {
#[serde(default)]
pub timestamp: i64,
#[serde(default)]
pub cpu: f64,
#[serde(default)]
pub load1: f64,
#[serde(default)]
pub load5: f64,
#[serde(default)]
pub load15: f64,
#[serde(default)]
pub mem_used: i64,
#[serde(default)]
pub mem_total: i64,
#[serde(default)]
pub swap_used: i64,
#[serde(default)]
pub swap_total: i64,
#[serde(default)]
pub disk_used: i64,
#[serde(default)]
pub disk_total: i64,
#[serde(default)]
pub net_in: f64,
#[serde(default)]
pub net_out: f64,
#[serde(default)]
pub net_rx_total: i64,
#[serde(default)]
pub net_tx_total: i64,
#[serde(default)]
pub uptime: i64,
#[serde(default)]
pub processes: i64,
#[serde(default)]
pub tcp_connections: i64,
#[serde(default)]
pub udp_connections: i64,
#[serde(default)]
pub cpu_cores: i64,
#[serde(default)]
pub cpu_model: String,
#[serde(default)]
pub os: String,
#[serde(default)]
pub kernel: String,
#[serde(default)]
pub arch: String,
#[serde(default)]
pub virtualization: String,
#[serde(default)]
pub ipv4: String,
#[serde(default)]
pub ipv6: String,
#[serde(default)]
pub gpu_usage: f64,
#[serde(default)]
pub gpu_model: String,
#[serde(default)]
pub agent_version: String,
#[serde(default)]
pub disk_read_bps: f64,
#[serde(default)]
pub disk_write_bps: f64,
#[serde(default)]
pub disk_read_iops: f64,
#[serde(default)]
pub disk_write_iops: f64,
#[serde(default)]
pub disk_await_ms: f64,
#[serde(default)]
pub disk_utilization: f64,
#[serde(default)]
pub disks: Vec<AgentDiskMetric>,
#[serde(default)]
pub gpus: Vec<AgentGpuMetric>,
#[serde(default)]
pub message: String,
#[serde(default)]
pub latency_results: Vec<AgentLatencyResult>,
}
@@ -294,7 +225,6 @@ pub struct ServerView {
pub tags: String,
pub note: String,
pub hidden: i64,
pub sort_order: i64,
pub expires_at: Option<i64>,
pub traffic_limit: i64,
pub traffic_limit_type: String,
@@ -311,8 +241,8 @@ pub struct ServerView {
pub tx_correction: i64,
pub offline_notify_disabled: i64,
pub auto_update: i64,
#[serde(skip_serializing)]
pub created_at: i64,
pub updated_at: i64,
pub timestamp: Option<i64>,
pub cpu: Option<f64>,
pub load1: Option<f64>,
@@ -338,8 +268,6 @@ pub struct ServerView {
pub kernel: Option<String>,
pub arch: Option<String>,
pub virtualization: Option<String>,
pub ipv4: Option<String>,
pub ipv6: Option<String>,
pub gpu_usage: Option<f64>,
pub gpu_model: Option<String>,
pub agent_version: Option<String>,
@@ -349,7 +277,9 @@ pub struct ServerView {
pub disk_write_iops: Option<f64>,
pub disk_await_ms: Option<f64>,
pub disk_utilization: Option<f64>,
#[serde(skip_serializing)]
pub disk_info: Option<String>,
#[serde(skip_serializing)]
pub gpu_info: Option<String>,
pub message: Option<String>,
}
@@ -389,30 +319,28 @@ pub struct TokenHashRow {
pub hidden: i64,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Serialize)]
pub struct AgentConfigView {
pub report_interval: i64,
pub collect_interval: i64,
pub network_interface: String,
pub auto_update: i64,
#[serde(default)]
pub latency_tasks: Vec<AgentLatencyTask>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AlertRuleInput {
pub name: String,
pub metric: String,
pub threshold: f64,
pub duration_minutes: i64,
pub aggregation: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub server_ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct AlertRuleView {
pub id: String,
pub name: String,
@@ -421,10 +349,7 @@ pub struct AlertRuleView {
pub duration_minutes: i64,
pub aggregation: String,
pub enabled: i64,
#[serde(default)]
pub server_ids: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Serialize)]
+29 -6
View File
@@ -5,7 +5,7 @@ use worker::{wasm_bindgen::JsValue, D1Database, Fetch, Method, Request, RequestI
use crate::db::{self, SettingsView};
#[derive(Default, Deserialize, Serialize)]
#[derive(Default, Deserialize, Serialize, PartialEq, Eq)]
struct AlertState {
offline: HashSet<String>,
expiry: HashSet<String>,
@@ -63,7 +63,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
let servers = db::list_servers(db_conn, true).await?;
let stored = db::get_setting(db_conn, "alert_state").await?;
let mut previous: AlertState = stored
let previous: AlertState = stored
.as_deref()
.and_then(|value| serde_json::from_str(value).ok())
.unwrap_or_default();
@@ -72,7 +72,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
let offline_after = settings.offline_alert_minutes.clamp(2, 1440) * 60;
let mut messages = Vec::new();
for server in servers {
for server in &servers {
let server_id = server.id.clone();
let is_offline = server
.timestamp
@@ -119,10 +119,26 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
let previous_resources = db::active_alert_states(db_conn).await?;
let mut current_resources = HashSet::new();
let mut evaluated_resources = HashSet::new();
let mut eligible_resources = HashSet::new();
for rule in db::list_alert_rules(db_conn).await? {
if rule.enabled == 0 {
continue;
}
if rule.server_ids.is_empty() {
eligible_resources.extend(
servers
.iter()
.filter(|server| server.hidden == 0)
.map(|server| format!("{}:{}", rule.id, server.id)),
);
} else {
eligible_resources.extend(
rule.server_ids
.iter()
.map(|server_id| format!("{}:{server_id}", rule.id)),
);
}
let metric_label = match rule.metric.as_str() {
"cpu" => "CPU",
"memory" => "内存",
@@ -138,6 +154,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
};
for value in db::alert_metric_values(db_conn, &rule, current_time).await? {
let key = format!("{}:{}", rule.id, value.server_id);
evaluated_resources.insert(key.clone());
if value.value >= rule.threshold {
current_resources.insert(key.clone());
if !previous_resources.contains(&key) {
@@ -163,12 +180,18 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
}
}
}
for key in &previous_resources {
if eligible_resources.contains(key) && !evaluated_resources.contains(key) {
current_resources.insert(key.clone());
}
}
if !messages.is_empty() {
let body = format!("NodeFlare 告警\n\n{}", messages.join("\n"));
send(settings, &body).await?;
}
previous = current;
db::save_setting(db_conn, "alert_state", &serde_json::to_string(&previous)?).await?;
db::replace_active_alert_states(db_conn, &current_resources).await
if previous != current {
db::save_setting(db_conn, "alert_state", &serde_json::to_string(&current)?).await?;
}
db::sync_active_alert_states(db_conn, &previous_resources, &current_resources).await
}
+218 -1
View File
@@ -1,4 +1,185 @@
use futures_util::TryStreamExt;
use serde_json::Value;
use worker::{Error, Fetch, Method, Request, Response, Result, Url};
pub const BUILTIN_THEME_ID: &str = "builtin-komari-glass";
pub const BUILTIN_THEME_NAME: &str = "Komari Glass";
pub const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024;
pub const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024;
pub struct ResolvedTheme {
pub source_url: String,
pub resolved_url: String,
}
pub fn normalize_url(value: &str) -> Option<String> {
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() != ""
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return None;
}
let path = parsed.path().trim_matches('/');
let parts: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect();
if parts.len() < 4
|| parts[2] != "tree"
|| parts.iter().any(|part| {
part.is_empty()
|| !part
.chars()
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
|| matches!(*part, "." | "..")
})
{
return None;
}
Some(format!("https://github.com/{}", parts.join("/")))
}
pub fn resolve_url(value: &str) -> Result<ResolvedTheme> {
let source_url =
normalize_url(value).ok_or_else(|| Error::RustError("主题 URL 格式无效".to_string()))?;
let parsed = Url::parse(&source_url)?;
let parts: Vec<_> = parsed
.path()
.trim_matches('/')
.split('/')
.filter(|part| !part.is_empty())
.collect();
let owner = parts[0];
let repository = parts[1];
let reference = parts[3];
let subdirectory = parts[4..].join("/");
let suffix = if subdirectory.is_empty() {
String::new()
} else {
format!("/{subdirectory}")
};
Ok(ResolvedTheme {
source_url,
resolved_url: format!(
"https://raw.githubusercontent.com/{owner}/{repository}/{reference}{suffix}"
),
})
}
fn remote_url(base: &str, relative: &str) -> String {
format!("{}/{}", base.trim_end_matches('/'), relative)
}
fn raw_github_base(base: &str) -> Option<Url> {
let parsed = Url::parse(&format!("{}/", base.trim_end_matches('/'))).ok()?;
if parsed.scheme() != "https"
|| parsed.host_str() != Some("raw.githubusercontent.com")
|| parsed.port().is_some()
|| parsed.username() != ""
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return None;
}
let parts = parsed
.path()
.trim_matches('/')
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
(parts.len() >= 3
&& parts.iter().all(|part| {
!matches!(*part, "." | "..")
&& part
.chars()
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
}))
.then_some(parsed)
}
pub fn asset_url(base: &str, path: &str) -> Option<String> {
let relative = path.strip_prefix("/assets/")?;
if relative.is_empty()
|| relative.len() > 1024
|| relative
.chars()
.any(|character| character.is_control() || matches!(character, '%' | '\\' | '?' | '#'))
|| relative
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return None;
}
let base = raw_github_base(base)?;
let remote = base.join(&format!("assets/{relative}")).ok()?;
let expected_path = format!("{}assets/{relative}", base.path());
if remote.origin() != base.origin() || remote.path() != expected_path {
return None;
}
Some(remote.to_string())
}
pub fn index_url(base: &str) -> Option<String> {
raw_github_base(base).map(|base| remote_url(base.as_str(), "index.html"))
}
pub async fn read_response_limited(
response: &mut Response,
limit: usize,
) -> Result<Option<Vec<u8>>> {
let declared = response
.headers()
.get("Content-Length")?
.and_then(|value| value.parse::<usize>().ok());
if declared.is_some_and(|length| length == 0 || length > limit) {
return Ok(None);
}
let mut body = Vec::with_capacity(declared.unwrap_or(0).min(limit));
let mut stream = response.stream()?;
while let Some(mut chunk) = stream.try_next().await? {
let Some(length) = body.len().checked_add(chunk.len()) else {
return Ok(None);
};
if length > limit {
return Ok(None);
}
body.append(&mut chunk);
}
Ok((!body.is_empty()).then_some(body))
}
pub async fn validate_remote(base: &str) -> Result<()> {
let index = index_url(base)
.ok_or_else(|| Error::RustError("主题资源地址不是 GitHub Raw 地址".to_string()))?;
let request = Request::new(&index, Method::Get)?;
let mut response = Fetch::Request(request).send().await?;
let status = response.status_code();
if !(200..300).contains(&status) {
return Err(Error::RustError(format!(
"主题 index.html 返回 HTTP {status}"
)));
}
let body = read_response_limited(&mut response, INDEX_MAX_BYTES)
.await?
.ok_or_else(|| Error::RustError("主题 index.html 内容无效".to_string()))?;
let body = String::from_utf8(body)
.map_err(|_| Error::RustError("主题 index.html 不是 UTF-8 文本".to_string()))?;
if body.trim().is_empty() {
return Err(Error::RustError("主题 index.html 内容无效".to_string()));
}
Ok(())
}
pub fn settings_schema() -> Value {
let currencies = [
@@ -35,7 +216,7 @@ pub fn settings_schema() -> Value {
#[cfg(test)]
mod tests {
use super::settings_schema;
use super::{asset_url, normalize_url, resolve_url, settings_schema};
#[test]
fn exposes_builtin_glass_theme_settings() {
@@ -45,4 +226,40 @@ mod tests {
assert!(settings.iter().any(|field| field["key"] == "enableBlur"));
assert_eq!(schema["source"], "builtin");
}
#[test]
fn accepts_only_github_tree_urls() {
assert_eq!(
normalize_url("https://github.com/acme/theme/tree/abc123").as_deref(),
Some("https://github.com/acme/theme/tree/abc123")
);
assert!(normalize_url("https://themes.example.com/nodeflare").is_none());
assert!(normalize_url("http://themes.example.com/theme").is_none());
assert!(normalize_url("https://user@example.com/theme").is_none());
assert!(normalize_url("https://github.com/acme/theme").is_none());
assert_eq!(
resolve_url("https://github.com/acme/theme/tree/main/dist")
.expect("GitHub theme URL")
.resolved_url,
"https://raw.githubusercontent.com/acme/theme/main/dist"
);
}
#[test]
fn limits_proxy_paths_to_theme_assets() {
assert_eq!(
asset_url(
"https://raw.githubusercontent.com/acme/theme/main",
"/assets/app.js"
)
.as_deref(),
Some("https://raw.githubusercontent.com/acme/theme/main/assets/app.js")
);
assert!(asset_url("https://example.com/theme", "/assets/app.js").is_none());
assert!(asset_url("https://example.com/theme", "/logo.svg").is_none());
assert!(asset_url("https://example.com/theme", "/assets/../secret").is_none());
assert!(asset_url("https://example.com/theme", "/assets/%2e%2e/secret").is_none());
assert!(asset_url("https://example.com/theme", "/assets/..\\secret").is_none());
assert!(asset_url("https://example.com/theme", "/assets/a%2fb.js").is_none());
}
}
+24
View File
@@ -19,6 +19,30 @@ binding = "DB"
# Wrangler 4.45+ provisions and binds this database when database_id is omitted.
database_name = "nodeflare"
[[ratelimits]]
name = "API_RATE_LIMITER"
namespace_id = "73120401"
[ratelimits.simple]
limit = 300
period = 60
[[ratelimits]]
name = "AUTH_RATE_LIMITER"
namespace_id = "73120402"
[ratelimits.simple]
limit = 20
period = 60
[[ratelimits]]
name = "AGENT_RATE_LIMITER"
namespace_id = "73120403"
[ratelimits.simple]
limit = 120
period = 60
[[durable_objects.bindings]]
name = "LIVE_HUB"
class_name = "LiveHub"