commit 143ab369c9262a5e75c3555fe29658cefc201c06 Author: chick Date: Tue Jul 7 21:26:43 2026 +0800 Initial Multi SimAdmin dashboard diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..716065e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +config.json +.env +.env.* +.DS_Store +npm-debug.log* +coverage/ +.hermes/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..636803c --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Multi SimAdmin + +一个用于聚合管理多个 [SimAdmin](https://github.com/3899/SimAdmin) 实例的轻量本地运维面板。 + +## 功能 + +- 只需要在 `config.json` 配置多个 SimAdmin 地址。 +- 深色专业 Dashboard:实例舰队列表、状态筛选、搜索、当前设备摘要。 +- 支持无密码实例状态读取;有密码实例可通过本地服务代登录并保持会话。 +- 统一代理 SimAdmin API,内置 API 工作台,便于读取/调试设备、SIM、网络、短信、eSIM、OTA 等接口。 +- 保留原始 SimAdmin 页面 iframe 嵌入;若目标站禁止 iframe,可一键在原站打开。 +- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。 + +## 快速开始 + +```bash +cp config.example.json config.json +npm install +npm start +``` + +默认访问: + +## 配置 + +编辑 `config.json`: + +```json +{ + "server": { "host": "0.0.0.0", "port": 8788 }, + "instances": [ + { + "id": "simadmin-open", + "name": "无密码 SimAdmin", + "url": "http://192.168.68.1:3000", + "description": "密码保护关闭或尚未配置密码的实例", + "auth": { "mode": "none" }, + "tags": ["open"] + }, + { + "id": "simadmin-password", + "name": "有密码 SimAdmin", + "url": "http://192.168.68.2:3000", + "description": "服务端代登录并保存会话 Cookie;密码只保存在本地 config.json", + "auth": { + "mode": "password", + "password": "CHANGE_ME" + }, + "tags": ["password"] + } + ] +} +``` + +字段说明: + +- `id`:唯一标识,只能包含字母、数字、下划线、短横线、点号。 +- `name`:页面显示名称。 +- `url`:SimAdmin 原始访问地址。 +- `description`:可选描述。 +- `auth.mode`:`none` 表示无密码;`password` 表示由本地聚合服务代登录。 +- `auth.password`:可选。本地保存后可自动刷新会话;不要提交真实密码。 + +## 常用命令 + +```bash +npm test +npm start +``` + +## 注意 + +- 当前项目是本地管理面板,不修改 SimAdmin 本体。 +- `config.json` 可能包含真实设备地址和密码,已在 `.gitignore` 中排除。 +- 部分写操作接口(短信发送、网络模式、频段/小区锁定等)会改变设备状态,执行前请确认目标实例。 diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..bdc0a14 --- /dev/null +++ b/config.example.json @@ -0,0 +1,27 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8788 + }, + "instances": [ + { + "id": "simadmin-open", + "name": "无密码 SimAdmin", + "url": "http://192.168.68.1:3000", + "description": "密码保护关闭或尚未配置密码的实例", + "auth": { "mode": "none" }, + "tags": ["open"] + }, + { + "id": "simadmin-password", + "name": "有密码 SimAdmin", + "url": "http://192.168.68.2:3000", + "description": "服务端代登录并保存会话 Cookie;密码只保存在本地 config.json", + "auth": { + "mode": "password", + "password": "CHANGE_ME" + }, + "tags": ["password"] + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eeeb9f5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1047 @@ +{ + "name": "multi-simadmin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "multi-simadmin", + "version": "0.1.0", + "dependencies": { + "@fastify/static": "^8.3.0", + "fastify": "^5.6.2" + }, + "devDependencies": {} + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.0.tgz", + "integrity": "sha512-YV53BAbR3Qwq37wfD1oZ97YJ0nYj6CwfzKXQ38ock9XxI2EnLOdl5psKms6Evook6ACckytZJOaKY0ThmIi1uw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bcf451e --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "multi-simadmin", + "version": "0.1.0", + "description": "Config-driven dashboard to manage multiple SimAdmin instances from one page", + "type": "module", + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "test": "node --test" + }, + "dependencies": { + "@fastify/static": "^8.3.0", + "fastify": "^5.6.2" + }, + "devDependencies": {} +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..13fd4e0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,388 @@ +let instances = [] +let statuses = new Map() +let catalog = [] +let activeId = null +let activeFilter = 'all' +let activeModule = 'all' +let configPath = 'config.json' +let autoRefreshTimer = null + +const $ = (id) => document.getElementById(id) +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)] + +const modules = [ + { id: 'overview', title: '设备总览', desc: '设备、SIM、网络、连接状态与流量统计。适合日常巡检和资产核对。', tone: 'ok', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] }, + { id: 'sms', title: '短信中心', desc: '短信统计、列表、会话读取与短信发送。发送类接口请先在 API 工作台确认请求体。', tone: 'warn', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/conversation', '/api/sms/send'] }, + { id: 'network', title: '蜂窝网络', desc: '数据开关、漫游、飞行模式、无线制式、频段/小区锁定与 APN。', tone: 'danger', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] }, + { id: 'device-network', title: '设备网络', desc: 'WLAN、DDNS、连接地址与局域网访问能力。', tone: 'neutral', endpoints: ['/api/device-network/wlan/status', '/api/device-network/wlan/profiles', '/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/network/connection-addresses'] }, + { id: 'calls', title: '电话能力', desc: '通话列表、历史记录、IMS 与语音信箱状态。', tone: 'neutral', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] }, + { id: 'esim', title: 'eSIM / 工作模式', desc: 'eUICC、profile、LPAC 状态、工作模式与 eSIM 配置。', tone: 'warn', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] }, + { id: 'notify-auto-ota', title: '通知 / 自动化 / OTA', desc: '通知配置与日志、自动化任务日志、在线升级状态。', tone: 'neutral', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] }, +] + +function escapeHtml(text) { + return String(text ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])) +} +function value(v) { return v === undefined || v === null || v === '' ? '-' : String(v) } +function boolText(v) { + if (v === true) return '开启' + if (v === false) return '关闭' + return value(v) +} +function activeInstance() { return instances.find(x => x.id === activeId) } +function activeStatus() { return statuses.get(activeId) } +function statusKind(s) { + if (!s) return 'unknown' + if (!s.reachable) return 'offline' + if (s.authenticated === false) return 'auth' + return 'online' +} +function statusText(s) { + const kind = statusKind(s) + if (kind === 'online') return `在线${s.latencyMs != null ? ` ${s.latencyMs}ms` : ''}` + if (kind === 'auth') return '需登录' + if (kind === 'offline') return '离线' + return '未知' +} +function pillClass(s) { + const kind = statusKind(s) + if (kind === 'online') return 'ok' + if (kind === 'auth') return 'warn' + if (kind === 'offline') return 'bad' + return '' +} +function showToast(message, timeout = 2600) { + const toast = $('toast') + toast.textContent = message + toast.hidden = false + clearTimeout(showToast._timer) + showToast._timer = setTimeout(() => { toast.hidden = true }, timeout) +} +function flattenEndpoints() { + return [...new Set([ + ...catalog.flatMap(g => g.endpoints || []), + ...modules.flatMap(m => m.endpoints || []), + ])].sort() +} +function inferMethod(endpoint) { + if (/send|config|data$|roaming$|airplane-mode$|radio-mode$|band-lock$|cell-lock$|work-mode|login|logout/i.test(endpoint)) return 'POST' + return 'GET' +} +function proxiedUrl(endpoint) { + if (!activeId) return null + const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}` + return `/api/proxy/${encodeURIComponent(activeId)}${normalized}` +} + +function renderFleetStats() { + const total = instances.length + const counts = { online: 0, auth: 0, offline: 0, unknown: 0 } + for (const inst of instances) counts[statusKind(statuses.get(inst.id))] += 1 + $('fleetStats').innerHTML = [ + ['全部', total], ['在线', counts.online], ['需登录', counts.auth], ['离线', counts.offline], + ].map(([label, num]) => `
${num}${label}
`).join('') +} + +function filteredInstances() { + const q = $('instanceSearch')?.value.trim().toLowerCase() || '' + return instances.filter(item => { + const s = statuses.get(item.id) + const kind = statusKind(s) + if (activeFilter !== 'all' && kind !== activeFilter) return false + if (!q) return true + const haystack = [item.id, item.name, item.url, item.description, JSON.stringify(s?.summary || {})].join(' ').toLowerCase() + return haystack.includes(q) + }) +} + +function renderInstances() { + const root = $('instances') + renderFleetStats() + if (!instances.length) { + root.innerHTML = '

未配置实例

请编辑 config.json 添加 instances,并重启服务。

' + return + } + const list = filteredInstances() + if (!list.length) { + root.innerHTML = '

没有匹配设备

调整搜索关键字或状态筛选。

' + return + } + root.innerHTML = list.map(item => { + const s = statuses.get(item.id) + const sum = s?.summary || {} + const network = sum.network || {} + const sim = sum.sim || {} + return `
+
+
+
${escapeHtml(item.name || item.id)}
+
${escapeHtml(item.url)}
+
+ ${escapeHtml(statusText(s))} +
+ ${item.description ? `

${escapeHtml(item.description)}

` : ''} +
+ 认证${item.auth?.hasPassword ? '密码会话' : '无密码'} + 运营商${escapeHtml(value(network.operator || sim.operator))} + 网络${escapeHtml(value(network.accessTechnology || network.registration))} + ICCID${escapeHtml(value(sim.iccid))} +
+
` + }).join('') + $$('.instance-card', root).forEach(card => card.addEventListener('click', () => selectInstance(card.dataset.id))) +} + +function renderHero() { + const item = activeInstance() + const s = activeStatus() + const sum = s?.summary || {} + if (!item) { + $('activeKicker').textContent = 'NO DEVICE SELECTED' + $('activeName').textContent = '选择一个 SimAdmin 实例' + $('activeMeta').textContent = '统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。' + $('signalPanel').innerHTML = [ + ['状态', '等待选择'], ['当前实例', '-'], ['会话', '-'], ['刷新', '-'], + ].map(([k, v]) => `
${k}${v}
`).join('') + return + } + $('activeKicker').textContent = item.id + $('activeName').textContent = item.name || item.id + $('activeMeta').textContent = item.description ? `${item.description} · ${item.url}` : item.url + $('signalPanel').innerHTML = [ + ['状态', statusText(s)], + ['设备', value(sum.device?.model || sum.device?.name)], + ['运营商', value(sum.network?.operator || sum.sim?.operator)], + ['网络', value(sum.network?.accessTechnology || sum.network?.registration)], + ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], + ['版本', value(sum.ota?.currentVersion || sum.device?.firmwareVersion)], + ].map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join('') +} + +function renderOverview() { + const item = activeInstance() + const s = activeStatus() + if (!item) { + $('overview').innerHTML = '
提示先选择左侧设备
' + renderHero() + return + } + const sum = s?.summary || {} + const metrics = [ + ['状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : `离线`) : '未知'], + ['延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'], + ['型号', sum.device?.model || sum.device?.name], + ['IMEI', sum.device?.imei], + ['ICCID', sum.sim?.iccid], + ['号码', sum.sim?.phoneNumber || sum.sim?.msisdn], + ['运营商', sum.network?.operator || sum.sim?.operator], + ['网络', sum.network?.accessTechnology || sum.network?.registration], + ['数据连接', sum.data ? `${boolText(sum.data.enabled)} / ${boolText(sum.data.connected)}` : '-'], + ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], + ] + $('overview').innerHTML = metrics.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(value(v))}
`).join('') + renderHero() +} + +function renderModuleTabs() { + const tabs = [{ id: 'all', title: '全部' }, ...modules.map(m => ({ id: m.id, title: m.title.split(/[ /]/)[0] }))] + $('moduleTabs').innerHTML = tabs.map(t => ``).join('') + $$('button[data-module]', $('moduleTabs')).forEach(btn => btn.addEventListener('click', () => { + activeModule = btn.dataset.module + renderFeatures() + })) +} + +function renderFeatures() { + renderModuleTabs() + const disabled = activeId ? '' : ' disabled' + const visible = activeModule === 'all' ? modules : modules.filter(m => m.id === activeModule) + $('featureGrid').innerHTML = visible.map(m => `
+
+

${escapeHtml(m.id)}

${escapeHtml(m.title)}

+ ${m.endpoints.length} API +
+

${escapeHtml(m.desc)}

+ ${m.tone === 'danger' ? '
包含写操作/网络切换类接口,执行前确认目标设备。
' : ''} +
${m.endpoints.map(ep => `${escapeHtml(ep)}`).join('')}
+
`).join('') + $$('button[data-ep]', $('featureGrid')).forEach(btn => btn.addEventListener('click', () => { + switchView('api') + $('endpointInput').value = btn.dataset.ep + $('methodSelect').value = inferMethod(btn.dataset.ep) + runEndpoint() + })) +} + +function renderEndpointList() { + const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints })) + $('endpointList').innerHTML = groups.map(group => `
+

${escapeHtml(group.name || group.id)}

+ ${(group.endpoints || []).map(ep => ``).join('')} +
`).join('') + $$('.endpoint-row', $('endpointList')).forEach(row => row.addEventListener('click', () => { + $('endpointInput').value = row.dataset.ep + $('methodSelect').value = inferMethod(row.dataset.ep) + $$('.endpoint-row', $('endpointList')).forEach(x => x.classList.toggle('active', x === row)) + })) +} + +function switchView(view) { + $$('.rail-btn[data-view]').forEach(btn => btn.classList.toggle('active', btn.dataset.view === view)) + $$('.view').forEach(panel => panel.classList.toggle('active', panel.id === `${view}View`)) + localStorage.setItem('multi-simadmin-view', view) +} + +function selectInstance(id) { + const item = instances.find(x => x.id === id) + if (!item) return + activeId = id + localStorage.setItem('multi-simadmin-active', id) + $('loginBtn').disabled = false + $('openExternal').href = item.url + $('openExternal').classList.remove('disabled') + $('emptyState').hidden = true + $('frame').hidden = false + $('frame').src = item.url + renderInstances() + renderOverview() + renderFeatures() +} + +async function loadConfig() { + const [configRes, catalogRes] = await Promise.all([fetch('/api/config'), fetch('/api/catalog')]) + if (!configRes.ok) throw new Error(`配置读取失败:HTTP ${configRes.status}`) + const data = await configRes.json() + instances = data.instances || [] + configPath = data.configPath || 'config.json' + $('configPath').textContent = configPath + if (catalogRes.ok) catalog = (await catalogRes.json()).groups || [] + const saved = localStorage.getItem('multi-simadmin-active') + if (saved && instances.some(x => x.id === saved)) activeId = saved + renderEndpointList() + renderInstances() + renderOverview() + renderFeatures() + if (activeId) selectInstance(activeId) +} + +async function refreshStatus({ silent = false } = {}) { + $('refreshBtn').disabled = true + if (!silent) showToast('正在刷新全部实例…', 1200) + try { + const res = await fetch('/api/status') + if (!res.ok) throw new Error(`状态读取失败:HTTP ${res.status}`) + const data = await res.json() + statuses = new Map((data.instances || []).map(item => [item.id, item])) + renderInstances() + renderOverview() + renderFeatures() + if (!silent) showToast(`已刷新 ${statuses.size} 个实例`) + } catch (error) { + showToast(error.message || '刷新失败', 4200) + } finally { + $('refreshBtn').disabled = false + } +} + +async function loginActive() { + if (!activeId) return + const item = activeInstance() + if (!item) return + // If config already has a saved password, try direct refresh first; otherwise ask for password. + if (item.auth?.hasPassword) { + showToast('正在使用本地配置密码刷新会话…', 1600) + const res = await fetch(`/api/instances/${encodeURIComponent(activeId)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}) }) + if (!res.ok) showToast(`登录失败:HTTP ${res.status}`, 4200) + await refreshStatus({ silent: true }) + return + } + $('loginDialog').showModal() + setTimeout(() => $('passwordInput').focus(), 50) +} + +function parseBody() { + const raw = $('requestBody').value.trim() + if (!raw) return undefined + return JSON.parse(raw) +} + +async function runEndpoint() { + if (!activeId) { + showToast('请先选择左侧设备') + return + } + const endpoint = $('endpointInput').value.trim() + const method = $('methodSelect').value + if (!endpoint.startsWith('/')) { + showToast('接口路径必须以 / 开头') + return + } + $('rawOutput').textContent = '调用中…' + try { + const init = { method } + if (method !== 'GET') { + init.headers = { 'content-type': 'application/json' } + const body = parseBody() + if (body !== undefined) init.body = JSON.stringify(body) + } + const started = performance.now() + const res = await fetch(proxiedUrl(endpoint), init) + const elapsed = Math.round(performance.now() - started) + const text = await res.text() + let formatted = text + try { formatted = JSON.stringify(JSON.parse(text), null, 2) } catch {} + $('rawOutput').textContent = `HTTP ${res.status} · ${elapsed}ms · ${method} ${endpoint}\n\n${formatted}` + } catch (error) { + $('rawOutput').textContent = error.message || String(error) + showToast('接口调用失败', 4200) + } +} + +function bindEvents() { + $('refreshBtn').addEventListener('click', () => refreshStatus()) + $('loginBtn').addEventListener('click', loginActive) + $('runEndpoint').addEventListener('click', runEndpoint) + $('instanceSearch').addEventListener('input', renderInstances) + $('densityBtn').addEventListener('click', () => { + document.body.classList.toggle('compact') + localStorage.setItem('multi-simadmin-density', document.body.classList.contains('compact') ? 'compact' : 'normal') + }) + $$('.rail-btn[data-view]').forEach(btn => btn.addEventListener('click', () => switchView(btn.dataset.view))) + $$('#statusFilters .chip').forEach(btn => btn.addEventListener('click', () => { + activeFilter = btn.dataset.filter + $$('#statusFilters .chip').forEach(x => x.classList.toggle('active', x === btn)) + renderInstances() + })) + $('loginForm').addEventListener('submit', async (event) => { + event.preventDefault() + if (!activeId) return + const password = $('passwordInput').value + $('passwordInput').value = '' + $('loginDialog').close() + const res = await fetch(`/api/instances/${encodeURIComponent(activeId)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password }) }) + if (!res.ok) showToast(`登录失败:HTTP ${res.status}`, 4200) + else showToast('会话已刷新') + await refreshStatus({ silent: true }) + }) + $('endpointInput').addEventListener('keydown', (event) => { + if (event.key === 'Enter') runEndpoint() + }) +} + +function restorePrefs() { + if (localStorage.getItem('multi-simadmin-density') === 'compact') document.body.classList.add('compact') + switchView(localStorage.getItem('multi-simadmin-view') || 'dashboard') +} + +try { + restorePrefs() + bindEvents() + await loadConfig() + await refreshStatus({ silent: true }) + autoRefreshTimer = setInterval(() => refreshStatus({ silent: true }), 30000) + window.addEventListener('beforeunload', () => clearInterval(autoRefreshTimer)) +} catch (error) { + console.error(error) + showToast(error.message || '初始化失败', 8000) + $('rawOutput').textContent = error.stack || String(error) +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a51471b --- /dev/null +++ b/public/index.html @@ -0,0 +1,141 @@ + + + + + + Multi SimAdmin Control + + + + + + +
+ + + + +
+
+
+

NO DEVICE SELECTED

+

选择一个 SimAdmin 实例

+

统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。

+
+
+ + 原站打开 +
+
+ +
+
+
+

CONTROL SURFACE

+

统一运维所有 SimAdmin 节点

+

无密码实例直接读取;有密码实例由本地服务保存会话。所有敏感凭据只留在本地配置。

+
+
+
+ +
+ +
+
+
+

MODULES

+

集成功能模块

+
+
+
+
+
+
+ +
+ +
+
+ + + +
+ +
选择设备后可调用统一代理接口。
+
+
+ +
+
+ 原始 SimAdmin 页面会在下方显示;若目标站禁止 iframe,请使用“原站打开”。 +
+
+

还没有选择设备

+

在左侧选择一个实例后,这里会加载原 SimAdmin 页面。

+
+ +
+
+
+ + + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..315ffac --- /dev/null +++ b/public/styles.css @@ -0,0 +1,13 @@ +:root{ + color-scheme:dark; + --bg:#08090a;--bg2:#0b0d12;--rail:#050607;--panel:#0f1011;--panel2:#14161a;--surface:rgba(255,255,255,.035);--surface2:rgba(255,255,255,.055); + --text:#f7f8f8;--text2:#d0d6e0;--muted:#8a8f98;--dim:#62666d;--line:rgba(255,255,255,.08);--line2:rgba(255,255,255,.05); + --accent:#7170ff;--accent2:#5e6ad2;--ok:#27a644;--warn:#d99a20;--bad:#ef4444;--lime:#c2ef4e; + --font:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace; + font-family:var(--font);font-feature-settings:"cv01","ss03"; +} +*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:radial-gradient(900px 520px at 70% -10%,rgba(113,112,255,.18),transparent 55%),radial-gradient(720px 480px at 15% 10%,rgba(194,239,78,.055),transparent 50%),var(--bg);color:var(--text);overflow:hidden}button,input,select,textarea{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid rgba(113,112,255,.65);outline-offset:2px} +#appShell{height:100vh;display:grid;grid-template-columns:64px 390px minmax(0,1fr)}.rail{background:linear-gradient(180deg,#050607,#08090a);border-right:1px solid var(--line2);padding:14px 10px;display:flex;flex-direction:column;align-items:center;gap:18px}.brand-mark{width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,var(--accent2),#9b8cff);display:grid;place-items:center;box-shadow:0 0 0 1px rgba(255,255,255,.12),0 12px 34px rgba(94,106,210,.28)}.brand-mark span{font-size:12px;font-weight:600;letter-spacing:-.3px}.rail-nav{display:flex;flex-direction:column;gap:8px}.rail-btn{width:42px;height:42px;border:1px solid var(--line2);border-radius:12px;background:rgba(255,255,255,.02);color:var(--muted);font-family:var(--mono);font-size:13px}.rail-btn:hover,.rail-btn.active{color:var(--text);background:rgba(255,255,255,.06);border-color:rgba(113,112,255,.45)}.rail-btn.bottom{margin-top:auto} +.sidebar{background:rgba(15,16,17,.78);backdrop-filter:blur(24px);border-right:1px solid var(--line);padding:22px;display:flex;flex-direction:column;min-height:0}.sidebar-head,.workspace-head,.section-title,.console-toolbar,.frame-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px}.eyebrow{margin:0 0 6px;color:var(--dim);font:500 11px/1.2 var(--mono);letter-spacing:.08em;text-transform:uppercase}.eyebrow.accent{color:var(--accent)}h1,h2,h3,p{margin:0}h1{font-size:21px;font-weight:510;letter-spacing:-.46px}h2{font-size:30px;font-weight:510;letter-spacing:-.66px;line-height:1.08}h3{font-size:18px;font-weight:590;letter-spacing:-.22px}.icon-action,.ghost-btn,.primary-btn,.chip{border:1px solid var(--line);border-radius:10px;background:rgba(255,255,255,.025);color:var(--text2);padding:9px 12px;text-decoration:none;transition:.16s ease}.icon-action{width:38px;height:38px;padding:0}.ghost-btn:hover,.icon-action:hover,.chip:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:var(--text)}.primary-btn{background:var(--accent2);border-color:rgba(255,255,255,.12);color:#fff;box-shadow:0 0 0 1px rgba(255,255,255,.05) inset,0 10px 30px rgba(94,106,210,.24)}.primary-btn:hover{background:#828fff}.disabled,button:disabled{opacity:.45;pointer-events:none}.fleet-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:18px 0}.fleet-card{border:1px solid var(--line2);border-radius:13px;background:var(--surface);padding:10px}.fleet-card b{display:block;font-size:20px;font-weight:510;letter-spacing:-.44px}.fleet-card span{display:block;margin-top:3px;color:var(--dim);font-size:11px}.search-wrap{height:42px;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025);display:flex;align-items:center;gap:8px;padding:0 12px;color:var(--dim)}.search-wrap input{width:100%;border:0;outline:0;background:transparent;color:var(--text);font-size:14px}.filter-row{display:flex;gap:8px;margin:12px 0 16px;flex-wrap:wrap}.chip{padding:6px 10px;border-radius:999px;font-size:12px;color:var(--muted)}.chip.active{background:rgba(113,112,255,.14);border-color:rgba(113,112,255,.38);color:#cfd2ff}.instance-list{overflow:auto;display:flex;flex-direction:column;gap:10px;padding-right:3px}.instance-card{position:relative;border:1px solid var(--line2);border-radius:16px;background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.025));padding:14px;transition:.16s ease}.instance-card:hover{border-color:rgba(255,255,255,.14);transform:translateY(-1px)}.instance-card.active{border-color:rgba(113,112,255,.58);box-shadow:0 0 0 3px rgba(113,112,255,.12)}.instance-top{display:flex;justify-content:space-between;gap:10px}.instance-name{font-size:15px;font-weight:590}.instance-url{margin-top:4px;color:var(--dim);font:12px var(--mono);word-break:break-all}.instance-desc{margin-top:8px;color:var(--muted);font-size:12px;line-height:1.45}.status-pill{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;border-radius:999px;padding:5px 8px;border:1px solid var(--line2);font-size:11px;color:var(--muted)}.status-pill:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--dim)}.status-pill.ok{color:#b9f6c6}.status-pill.ok:before{background:var(--ok);box-shadow:0 0 12px rgba(39,166,68,.6)}.status-pill.warn{color:#ffd895}.status-pill.warn:before{background:var(--warn)}.status-pill.bad{color:#ffb4b4}.status-pill.bad:before{background:var(--bad)}.mini-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:12px}.mini-grid span{min-width:0;color:var(--dim);font-size:11px}.mini-grid b{display:block;color:var(--text2);font-weight:510;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-foot{border-top:1px solid var(--line2);margin-top:16px;padding-top:14px;color:var(--dim);font-size:12px}.sidebar-foot code{display:block;margin-top:6px;color:var(--muted);font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.workspace{min-width:0;display:flex;flex-direction:column;height:100vh}.workspace-head{height:92px;padding:20px 28px;border-bottom:1px solid var(--line);background:rgba(8,9,10,.62);backdrop-filter:blur(18px)}.title-block{min-width:0}.title-block p:last-child{margin-top:6px;color:var(--muted);font-size:14px}.head-actions{display:flex;gap:10px;white-space:nowrap}.view{display:none;min-height:0;flex:1;padding:20px 24px;overflow:auto}.view.active{display:block}.hero-panel{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:22px;background:linear-gradient(135deg,rgba(255,255,255,.055),rgba(255,255,255,.02)),radial-gradient(500px 240px at 80% 0%,rgba(113,112,255,.16),transparent 70%);padding:22px;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:18px}.hero-copy h3{font-size:32px;font-weight:510;letter-spacing:-.704px;line-height:1.05}.hero-copy p:last-child{max-width:680px;margin-top:10px;color:var(--muted);line-height:1.6}.signal-panel{border:1px solid var(--line2);border-radius:18px;background:rgba(0,0,0,.18);padding:14px;display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.signal-item{border:1px solid var(--line2);border-radius:13px;background:rgba(255,255,255,.025);padding:12px}.signal-item span{display:block;color:var(--dim);font:11px var(--mono);text-transform:uppercase}.signal-item b{display:block;margin-top:8px;font-size:19px;font-weight:510}.metric-grid{display:grid;grid-template-columns:repeat(5,minmax(140px,1fr));gap:12px;margin:16px 0}.metric{border:1px solid var(--line2);border-radius:16px;background:var(--surface);padding:14px}.metric span{display:block;color:var(--dim);font-size:12px}.metric b{display:block;margin-top:9px;font-size:18px;font-weight:510;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.section-block{border:1px solid var(--line);border-radius:20px;background:rgba(255,255,255,.025);padding:18px}.section-title{margin-bottom:14px}.section-title.small{margin-bottom:10px}.module-tabs{display:flex;gap:8px;flex-wrap:wrap}.module-tabs button{border:1px solid var(--line2);border-radius:999px;background:transparent;color:var(--muted);padding:7px 10px;font-size:12px}.module-tabs button.active{color:#cfd2ff;background:rgba(113,112,255,.12);border-color:rgba(113,112,255,.34)}.feature-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}.feature{border:1px solid var(--line2);border-radius:17px;background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.018));padding:15px;min-height:164px}.feature-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.feature h4{margin:0;font-size:16px;font-weight:590}.feature p{margin-top:8px;color:var(--muted);font-size:13px;line-height:1.5}.endpoint-pills{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px}.endpoint-pills button{border:1px solid var(--line2);border-radius:8px;background:rgba(255,255,255,.025);color:var(--muted);font:11px var(--mono);padding:6px 7px}.endpoint-pills button:hover{color:var(--text);border-color:rgba(113,112,255,.4)}.danger-note{margin-top:12px;color:#ffb4b4;font-size:12px}.api-layout.active{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px}.api-catalog,.api-console{border:1px solid var(--line);border-radius:20px;background:rgba(255,255,255,.025);padding:16px;min-height:0}.api-catalog{overflow:auto}.endpoint-group{margin-bottom:14px}.endpoint-group h4{margin:0 0 8px;font-size:13px;color:var(--text2);font-weight:590}.endpoint-row{display:block;width:100%;text-align:left;border:1px solid var(--line2);border-radius:10px;background:transparent;color:var(--muted);font:12px var(--mono);padding:8px 9px;margin-bottom:6px}.endpoint-row:hover,.endpoint-row.active{color:var(--text);background:rgba(255,255,255,.045);border-color:rgba(113,112,255,.34)}.api-console{display:flex;flex-direction:column;gap:12px}.console-toolbar{justify-content:flex-start}.console-toolbar select{width:110px}.console-toolbar input{flex:1;font-family:var(--mono)}input,select,textarea{border:1px solid var(--line);background:rgba(255,255,255,.025);color:var(--text);border-radius:10px;padding:10px 12px}.request-body{min-height:96px;resize:vertical;font-family:var(--mono)}.raw-output{flex:1;min-height:420px;margin:0;border:1px solid var(--line2);border-radius:16px;background:#050607;color:#dbeafe;padding:16px;overflow:auto;font:12px/1.6 var(--mono)}.rawpage-view.active{display:flex;flex-direction:column;gap:12px}.frame-toolbar{border:1px solid var(--line2);border-radius:14px;background:var(--surface);padding:12px 14px;color:var(--muted);font-size:13px}.frame{width:100%;flex:1;min-height:600px;border:1px solid var(--line);border-radius:18px;background:white}.empty-state{border:1px dashed var(--line);border-radius:20px;background:rgba(255,255,255,.025);padding:42px;color:var(--muted)}.empty-state h3{color:var(--text);margin-bottom:8px}dialog{border:1px solid var(--line);border-radius:20px;background:#101116;color:var(--text);box-shadow:0 28px 80px rgba(0,0,0,.5)}dialog::backdrop{background:rgba(0,0,0,.72);backdrop-filter:blur(6px)}.login-card{width:min(420px,86vw);padding:22px}.login-card p:not(.eyebrow){color:var(--muted);line-height:1.5;margin-top:8px}.login-card input{width:100%;margin:16px 0}.login-card menu{display:flex;justify-content:flex-end;gap:10px;margin:0;padding:0}.toast{position:fixed;right:20px;bottom:20px;border:1px solid var(--line);border-radius:14px;background:#191a1b;color:var(--text);padding:12px 14px;box-shadow:0 18px 60px rgba(0,0,0,.35);z-index:10}.compact .sidebar{padding:16px}.compact .instance-card,.compact .feature,.compact .metric{padding:10px}.compact .workspace-head{height:76px}.compact .view{padding:14px}.compact .metric-grid{gap:8px}.compact .feature-grid{gap:8px} +@media(max-width:1180px){#appShell{grid-template-columns:56px 330px minmax(0,1fr)}.metric-grid{grid-template-columns:repeat(3,1fr)}.feature-grid{grid-template-columns:repeat(2,1fr)}.hero-panel{grid-template-columns:1fr}.api-layout.active{grid-template-columns:1fr}.api-catalog{max-height:280px}}@media(max-width:820px){body{overflow:auto}#appShell{height:auto;min-height:100vh;grid-template-columns:1fr}.rail{display:none}.sidebar{height:46vh;border-right:0;border-bottom:1px solid var(--line)}.workspace{height:auto;min-height:54vh}.workspace-head{height:auto;align-items:flex-start;flex-direction:column}.metric-grid,.feature-grid{grid-template-columns:1fr}.signal-panel{grid-template-columns:1fr}} diff --git a/server/core.js b/server/core.js new file mode 100644 index 0000000..8d9728e --- /dev/null +++ b/server/core.js @@ -0,0 +1,293 @@ + +import { readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' + +export const DEFAULT_TIMEOUT_MS = Number(process.env.MULTI_SIMADMIN_TIMEOUT_MS || 6000) +const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length']) + +export function normalizeBaseUrl(rawUrl) { + const url = new URL(rawUrl) + url.hash = '' + url.search = '' + return url.toString().replace(/\/$/, '') +} + +export function normalizeInstance(instance, index = 0) { + if (!instance || typeof instance !== 'object') throw new Error(`instances[${index}] must be an object`) + const id = String(instance.id || '').trim() + const name = String(instance.name || id || `SimAdmin ${index + 1}`).trim() + const url = String(instance.url || '').trim() + if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error(`instances[${index}].id is required and may only contain letters, numbers, _, -, .`) + if (!url) throw new Error(`instances[${index}].url is required`) + const password = instance.auth?.password ?? instance.password ?? '' + return { + id, + name, + url: normalizeBaseUrl(url), + description: String(instance.description || '').trim(), + tags: Array.isArray(instance.tags) ? instance.tags.map(String) : [], + auth: { + mode: password ? 'password' : String(instance.auth?.mode || 'none'), + password: password ? String(password) : '', + }, + capabilities: Array.isArray(instance.capabilities) ? instance.capabilities.map(String) : [], + } +} + +export async function loadConfig({ configPath, defaultConfigPath }) { + const resolvedPath = existsSync(configPath) ? configPath : defaultConfigPath + const text = await readFile(resolvedPath, 'utf8') + const raw = JSON.parse(text) + const instances = Array.isArray(raw.instances) ? raw.instances.map(normalizeInstance) : [] + const ids = new Set() + for (const item of instances) { + if (ids.has(item.id)) throw new Error(`duplicate instance id: ${item.id}`) + ids.add(item.id) + } + return { + configPath: resolvedPath, + server: { + host: raw.server?.host || process.env.HOST || '0.0.0.0', + port: Number(process.env.PORT || raw.server?.port || 8788), + }, + instances, + } +} + +export function redactInstance(instance) { + return { + id: instance.id, + name: instance.name, + url: instance.url, + description: instance.description, + tags: instance.tags || [], + auth: { + mode: instance.auth?.mode || 'none', + hasPassword: Boolean(instance.auth?.password), + }, + capabilities: instance.capabilities || [], + } +} + +function apiData(payload) { + return payload?.data ?? payload +} + +function pickDevice(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + imei: data.imei || data.IMEI || null, + manufacturer: data.manufacturer || data.vendor || null, + model: data.model || data.device_model || null, + firmware: data.firmware || data.firmware_version || data.version || null, + online: data.online ?? data.is_online ?? null, + } +} + +function pickSim(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + iccid: data.iccid || data.ICCID || null, + imsi: data.imsi || data.IMSI || null, + phoneNumber: data.phone_number || data.phoneNumber || data.msisdn || null, + operator: data.operator || data.operator_name || null, + signal: data.signal || data.signal_strength || null, + } +} + +function pickNetwork(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + operator: data.operator || data.operator_name || data.provider || null, + registration: data.registration || data.registration_state || data.status || null, + accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || null, + signal: data.signal || data.signal_strength || data.rssi || null, + } +} + +function pickSms(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + total: data.total ?? data.total_count ?? data.count ?? null, + unread: data.unread ?? data.unread_count ?? null, + conversations: data.conversations ?? data.conversation_count ?? null, + } +} + +function pickDataStatus(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + enabled: data.enabled ?? data.data_enabled ?? null, + connected: data.connected ?? data.is_connected ?? null, + roaming: data.roaming ?? data.roaming_allowed ?? null, + } +} + +function pickOta(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + currentVersion: data.current_version || data.currentVersion || data.version || null, + latestVersion: data.latest_version || data.latestVersion || null, + updateAvailable: data.update_available ?? data.updateAvailable ?? null, + } +} + +export function summarizeInstanceSnapshot(raw) { + return { + device: pickDevice(raw.device), + sim: pickSim(raw.sim), + network: pickNetwork(raw.network), + sms: pickSms(raw.smsStats), + data: pickDataStatus(raw.data), + ota: pickOta(raw.ota), + stats: apiData(raw.stats) || null, + calls: apiData(raw.calls) || null, + } +} + +function parseSetCookie(value) { + if (!value) return [] + if (Array.isArray(value)) return value + return [String(value)] +} + +class CookieJar { + constructor() { this.map = new Map() } + setFromHeaders(headers) { + const raw = headers.getSetCookie ? headers.getSetCookie() : parseSetCookie(headers.get('set-cookie')) + for (const line of raw) { + const [pair] = String(line).split(';') + const eq = pair.indexOf('=') + if (eq <= 0) continue + const name = pair.slice(0, eq).trim() + const value = pair.slice(eq + 1).trim() + if (value) this.map.set(name, value) + else this.map.delete(name) + } + } + header() { + return [...this.map.entries()].map(([k, v]) => `${k}=${v}`).join('; ') + } + clear() { this.map.clear() } +} + +export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const jar = new CookieJar() + + async function request(endpoint, options = {}) { + const url = new URL(endpoint, instance.url) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs || timeoutMs) + const headers = new Headers(options.headers || {}) + const cookie = jar.header() + if (cookie && !headers.has('cookie')) headers.set('cookie', cookie) + if (headers.has('cookie')) headers.cookie = headers.get('cookie') + try { + const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' }) + jar.setFromHeaders(response.headers) + return response + } finally { + clearTimeout(timer) + } + } + + async function fetchJson(endpoint, options = {}) { + const startedAt = Date.now() + const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } }) + const text = await response.text() + let data = null + try { data = text ? JSON.parse(text) : null } catch {} + return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers } + } + + async function ensureAuthenticated() { + const status = await fetchJson('/api/auth/status') + const auth = apiData(status.data) || {} + const protectionEnabled = auth.settings?.password_protection_enabled ?? auth.settings?.passwordProtectionEnabled + if (auth.authenticated || protectionEnabled === false || auth.configured === false) { + return { configured: auth.configured ?? null, authenticated: Boolean(auth.authenticated || protectionEnabled === false), loginAttempted: false, statusCode: status.status } + } + if (!instance.auth?.password) return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' } + const login = await request('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ password: instance.auth.password }), + }) + return { configured: auth.configured ?? null, authenticated: login.ok, loginAttempted: true, statusCode: login.status, reason: login.ok ? null : 'login_failed' } + } + + return { instance, jar, request, fetchJson, ensureAuthenticated } +} + +export function buildClients(instances, options = {}) { + return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)])) +} + +export async function collectInstanceStatus(client) { + const result = { + ...redactInstance(client.instance), + reachable: false, + authenticated: null, + latencyMs: null, + statusCode: null, + error: null, + authStatus: null, + summary: summarizeInstanceSnapshot({}), + raw: {}, + checkedAt: new Date().toISOString(), + } + try { + const health = await client.fetchJson('/api/health') + result.reachable = health.ok + result.latencyMs = health.latencyMs + result.statusCode = health.status + result.raw.health = health.data || health.text || null + const auth = await client.ensureAuthenticated() + result.authenticated = auth.authenticated + result.authStatus = auth + const endpoints = { + device: '/api/device', sim: '/api/sim', network: '/api/network', smsStats: '/api/sms/stats', data: '/api/data', ota: '/api/ota/status', stats: '/api/stats', calls: '/api/calls' + } + await Promise.all(Object.entries(endpoints).map(async ([key, endpoint]) => { + try { + const response = await client.fetchJson(endpoint) + if (response.status === 401) result.authenticated = false + if (response.ok) result.raw[key] = response.data + } catch {} + })) + result.summary = summarizeInstanceSnapshot(result.raw) + } catch (error) { + result.error = error?.name === 'AbortError' ? 'timeout' : String(error?.message || error) + } + return result +} + +export function safeProxyPath(rest = '') { + const clean = String(rest || '').replace(/^\/+/, '') + if (!clean || clean === '..' || clean.includes('..')) throw new Error('invalid proxy path') + return `/${clean}` +} + +export async function proxyToInstance({ client, request, reply, rest }) { + const targetPath = safeProxyPath(rest) + const queryIndex = request.url.indexOf('?') + const query = queryIndex >= 0 ? request.url.slice(queryIndex) : '' + const headers = new Headers() + for (const [name, value] of Object.entries(request.headers || {})) { + if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value)) + } + const body = ['GET', 'HEAD'].includes(request.method) ? undefined : request.body + const upstream = await client.request(`${targetPath}${query}`, { method: request.method, headers, body, redirect: 'manual' }) + reply.code(upstream.status) + upstream.headers.forEach((value, name) => { + if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()) && name.toLowerCase() !== 'set-cookie') reply.header(name, value) + }) + return reply.send(Buffer.from(await upstream.arrayBuffer())) +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..ebcaeaa --- /dev/null +++ b/server/index.js @@ -0,0 +1,105 @@ +import Fastify from 'fastify' +import fastifyStatic from '@fastify/static' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + buildClients, + collectInstanceStatus, + loadConfig, + proxyToInstance, + redactInstance, +} from './core.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const ROOT = path.resolve(__dirname, '..') +const PUBLIC_DIR = path.join(ROOT, 'public') +const CONFIG_PATH = process.env.MULTI_SIMADMIN_CONFIG || path.join(ROOT, 'config.json') +const DEFAULT_CONFIG_PATH = path.join(ROOT, 'config.example.json') + +const config = await loadConfig({ configPath: CONFIG_PATH, defaultConfigPath: DEFAULT_CONFIG_PATH }) +const clients = buildClients(config.instances) +const app = Fastify({ logger: true, bodyLimit: 60 * 1024 * 1024 }) + +await app.register(fastifyStatic, { root: PUBLIC_DIR, prefix: '/' }) + +function clientById(id, reply) { + const client = clients.get(id) + if (!client) reply.code(404).send({ error: 'instance not found' }) + return client +} + +app.get('/api/config', async () => ({ + configPath: config.configPath, + instances: config.instances.map(redactInstance), + features: [ + 'passwordless-status', + 'password-session-login', + 'read-write-api-proxy', + 'device-sim-network-sms-data-ota-summary', + 'iframe-fallback', + ], +})) + +app.get('/api/instances', async () => ({ instances: config.instances.map(redactInstance) })) + +app.get('/api/status', async () => ({ + instances: await Promise.all([...clients.values()].map(collectInstanceStatus)), +})) + +app.get('/api/status/:id', async (request, reply) => { + const client = clientById(request.params.id, reply) + if (!client) return + return collectInstanceStatus(client) +}) + +app.post('/api/instances/:id/login', async (request, reply) => { + const client = clientById(request.params.id, reply) + if (!client) return + const password = request.body?.password || client.instance.auth?.password || '' + if (password) client.instance.auth.password = String(password) + const result = await client.ensureAuthenticated() + return { ...result, hasSavedPassword: Boolean(client.instance.auth?.password) } +}) + +app.post('/api/instances/:id/logout', async (request, reply) => { + const client = clientById(request.params.id, reply) + if (!client) return + try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {} + client.jar.clear() + return { ok: true } +}) + +app.all('/api/proxy/:id/*', async (request, reply) => { + const client = clientById(request.params.id, reply) + if (!client) return + return proxyToInstance({ client, request, reply, rest: request.params['*'] }) +}) + +app.get('/api/catalog', async () => ({ + groups: [ + { id: 'overview', name: '总览', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] }, + { id: 'sms', name: '短信', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation'] }, + { id: 'network', name: '网络/小区', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] }, + { id: 'device-network', name: '设备网络', endpoints: ['/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles'] }, + { id: 'calls', name: '电话', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] }, + { id: 'esim', name: 'eSIM', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] }, + { id: 'notify-auto-ota', name: '通知/自动化/升级', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] }, + ], +})) + +app.get('/api/reload-note', async () => ({ message: 'Edit config.json and restart this process to apply changes.' })) + +app.setNotFoundHandler(async (request, reply) => { + if (request.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' }) + return reply.sendFile('index.html') +}) + +try { + await app.listen({ host: config.server.host, port: config.server.port }) + app.log.info(`Multi SimAdmin listening on http://${config.server.host}:${config.server.port}`) + app.log.info(`Loaded ${config.instances.length} instance(s) from ${config.configPath}`) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/test/server-core.test.js b/test/server-core.test.js new file mode 100644 index 0000000..db04c67 --- /dev/null +++ b/test/server-core.test.js @@ -0,0 +1,56 @@ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance } from '../server/core.js' + +test('normalizeInstance supports passwordless and password-protected instances without exposing password', () => { + const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://127.0.0.1:3000/' }, 0) + assert.equal(open.auth.mode, 'none') + assert.equal(open.url, 'http://127.0.0.1:3000') + + const protectedOne = normalizeInstance({ id: 'locked', url: 'http://cpe.local', auth: { password: 'secret' } }, 1) + assert.equal(protectedOne.auth.mode, 'password') + assert.equal(protectedOne.auth.password, 'secret') + assert.equal(redactInstance(protectedOne).auth.hasPassword, true) + assert.equal(redactInstance(protectedOne).auth.password, undefined) +}) + +test('client stores simadmin_session from login and sends it on later proxied requests', async () => { + const calls = [] + const fetchImpl = async (url, options = {}) => { + calls.push({ url: String(url), options }) + if (String(url).endsWith('/api/auth/status') && calls.length === 1) { + return new Response(JSON.stringify({ data: { configured: true, authenticated: false, settings: { password_protection_enabled: true } } }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + if (String(url).endsWith('/api/auth/login')) { + return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'set-cookie': 'simadmin_session=abc123; HttpOnly; Path=/; Max-Age=86400' } }) + } + if (String(url).endsWith('/api/device')) { + return new Response(JSON.stringify({ data: { model: 'RM500Q' } }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + return new Response('{}', { status: 404 }) + } + const client = createSimAdminClient(normalizeInstance({ id: 'locked', url: 'http://sim.local', auth: { password: 'secret' } }, 0), { fetchImpl }) + const auth = await client.ensureAuthenticated() + assert.equal(auth.authenticated, true) + const proxied = await client.fetchJson('/api/device') + assert.equal(proxied.status, 200) + assert.match(calls.at(-1).options.headers.cookie, /simadmin_session=abc123/) +}) + +test('snapshot summary merges status from device, sim, network, sms and system endpoints', () => { + const snapshot = summarizeInstanceSnapshot({ + device: { data: { model: 'CPE X', imei: '123' } }, + sim: { data: { iccid: '8986', operator: 'CMCC' } }, + network: { data: { accessTechnology: 'LTE', registration: 'registered' } }, + smsStats: { data: { total: 12, unread: 2 } }, + data: { data: { enabled: true, connected: true } }, + ota: { data: { current_version: '1.2.3' } }, + }) + assert.equal(snapshot.device.model, 'CPE X') + assert.equal(snapshot.sim.iccid, '8986') + assert.equal(snapshot.network.accessTechnology, 'LTE') + assert.equal(snapshot.sms.total, 12) + assert.equal(snapshot.data.connected, true) + assert.equal(snapshot.ota.currentVersion, '1.2.3') +})