From 4783f51454ab3edd53938b06ae429d40de1ff687 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Fri, 26 Sep 2025 15:37:06 -0600 Subject: [PATCH] codigo refactorizado y ordenado, listo para siguiente fase --- docker-compose.yml | 3 +- frontend/index.html | 13 + frontend/package-lock.json | 1162 +++++++++++++++++++++++++++++++ frontend/package.json | 19 + frontend/src/App.vue | 149 ++++ frontend/src/main.js | 5 + frontend/vite.config.js | 19 + node-api/index.js | 513 +------------- node-api/src/app.js | 29 + node-api/src/config/env.js | 15 + node-api/src/routes/api.js | 92 +++ node-api/src/routes/radius.js | 94 +++ node-api/src/services/db.js | 92 +++ node-api/src/services/radius.js | 127 ++++ node-api/src/sse.js | 48 ++ node-api/src/utils/attrs.js | 29 + package.json | 2 + 17 files changed, 1900 insertions(+), 511 deletions(-) create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/vite.config.js create mode 100644 node-api/src/app.js create mode 100644 node-api/src/config/env.js create mode 100644 node-api/src/routes/api.js create mode 100644 node-api/src/routes/radius.js create mode 100644 node-api/src/services/db.js create mode 100644 node-api/src/services/radius.js create mode 100644 node-api/src/sse.js create mode 100644 node-api/src/utils/attrs.js diff --git a/docker-compose.yml b/docker-compose.yml index 74d6b85..f963c3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,8 @@ services: - PGPASSWORD=radius volumes: - ./node-api/index.js:/app/index.js:ro - - ./node-api/public:/app/public:ro + - ./node-api/src:/app/src:ro + - ./frontend/dist:/app/public:ro networks: - radius_net diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..46d44fc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + RADIUS Dashboard + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f740b50 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1162 @@ +{ + "name": "radius-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "radius-frontend", + "version": "0.1.0", + "dependencies": { + "vue": "^3.4.38" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.5", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz", + "integrity": "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.2.tgz", + "integrity": "sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.2.tgz", + "integrity": "sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.2.tgz", + "integrity": "sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.2.tgz", + "integrity": "sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.2.tgz", + "integrity": "sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.2.tgz", + "integrity": "sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.2.tgz", + "integrity": "sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.2.tgz", + "integrity": "sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.2.tgz", + "integrity": "sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.2.tgz", + "integrity": "sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.2.tgz", + "integrity": "sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.2.tgz", + "integrity": "sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.2.tgz", + "integrity": "sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.2.tgz", + "integrity": "sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.2.tgz", + "integrity": "sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.2.tgz", + "integrity": "sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.2.tgz", + "integrity": "sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.2.tgz", + "integrity": "sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.2.tgz", + "integrity": "sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.2.tgz", + "integrity": "sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.2.tgz", + "integrity": "sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz", + "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/shared": "3.5.22", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz", + "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", + "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/compiler-core": "3.5.22", + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.19", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz", + "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", + "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", + "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", + "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/runtime-core": "3.5.22", + "@vue/shared": "3.5.22", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", + "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "vue": "3.5.22" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz", + "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.52.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", + "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.2", + "@rollup/rollup-android-arm64": "4.52.2", + "@rollup/rollup-darwin-arm64": "4.52.2", + "@rollup/rollup-darwin-x64": "4.52.2", + "@rollup/rollup-freebsd-arm64": "4.52.2", + "@rollup/rollup-freebsd-x64": "4.52.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.2", + "@rollup/rollup-linux-arm-musleabihf": "4.52.2", + "@rollup/rollup-linux-arm64-gnu": "4.52.2", + "@rollup/rollup-linux-arm64-musl": "4.52.2", + "@rollup/rollup-linux-loong64-gnu": "4.52.2", + "@rollup/rollup-linux-ppc64-gnu": "4.52.2", + "@rollup/rollup-linux-riscv64-gnu": "4.52.2", + "@rollup/rollup-linux-riscv64-musl": "4.52.2", + "@rollup/rollup-linux-s390x-gnu": "4.52.2", + "@rollup/rollup-linux-x64-gnu": "4.52.2", + "@rollup/rollup-linux-x64-musl": "4.52.2", + "@rollup/rollup-openharmony-arm64": "4.52.2", + "@rollup/rollup-win32-arm64-msvc": "4.52.2", + "@rollup/rollup-win32-ia32-msvc": "4.52.2", + "@rollup/rollup-win32-x64-gnu": "4.52.2", + "@rollup/rollup-win32-x64-msvc": "4.52.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", + "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-sfc": "3.5.22", + "@vue/runtime-dom": "3.5.22", + "@vue/server-renderer": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..73ae48a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "radius-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.38" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.5", + "vite": "^5.4.8" + } +} + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..84dcfcc --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..0e698cc --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +createApp(App).mount('#app'); + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..a023ca8 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { target: 'http://localhost:3000', changeOrigin: true }, + '/events': { target: 'http://localhost:3000', changeOrigin: true, ws: false }, + '/authorize': { target: 'http://localhost:3000', changeOrigin: true }, + '/accounting': { target: 'http://localhost:3000', changeOrigin: true }, + '/post-auth': { target: 'http://localhost:3000', changeOrigin: true }, + '/authorize-inner': { target: 'http://localhost:3000', changeOrigin: true }, + '/test': { target: 'http://localhost:3000', changeOrigin: true } + } + } +}); + diff --git a/node-api/index.js b/node-api/index.js index 65d27f9..a15cb3f 100644 --- a/node-api/index.js +++ b/node-api/index.js @@ -1,515 +1,8 @@ -import express from 'express'; -import morgan from 'morgan'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import dgram from 'dgram'; -import radius from 'radius'; -import pkgPg from 'pg'; -const { Pool } = pkgPg; - -const app = express(); -app.use(express.json()); -app.use(morgan('dev')); - -// Static files for dashboard -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -app.use(express.static(path.join(__dirname, 'public'))); - -const VLAN_ID = process.env.VLAN_ID || '2'; -const MAX_UP = process.env.MAX_UP || '10000000'; // bits per second -const MAX_DOWN = process.env.MAX_DOWN || '10000000'; // bits per second -const MAX_REQUESTS = parseInt(process.env.MAX_REQUESTS || '200', 10); -const RADIUS_HOST = process.env.RADIUS_HOST || 'freeradius'; -const RADIUS_AUTH_PORT = parseInt(process.env.RADIUS_AUTH_PORT || '1812', 10); -const RADIUS_SECRET = process.env.RADIUS_SECRET || process.env.RADIUS_SHARED_SECRET || 'tamosbien'; - -// Requests store + SSE clients -const requests = []; -const sseClients = new Set(); -let radiusReloading = false; -// Active sessions indexed by Acct-Session-Id -const activeSessions = new Map(); - -function pushRequest(rec) { - requests.push(rec); - while (requests.length > MAX_REQUESTS) requests.shift(); - // Broadcast via SSE - const payload = `data: ${JSON.stringify(rec)}\n\n`; - for (const res of sseClients) { - try { res.write(payload); } catch { /* ignore */ } - } -} - -function broadcastStatus(payload) { - const ev = `event: status\n` + `data: ${JSON.stringify(payload)}\n\n`; - for (const res of sseClients) { try { res.write(ev); } catch {} - } -} - -// Postgres connection for user management (rlm_sql) -const PGHOST = process.env.PGHOST || 'postgres'; -const PGPORT = parseInt(process.env.PGPORT || '5432', 10); -const PGDATABASE = process.env.PGDATABASE || 'radius'; -const PGUSER = process.env.PGUSER || 'radius'; -const PGPASSWORD = process.env.PGPASSWORD || 'radius'; -const pool = new Pool({ host: PGHOST, port: PGPORT, database: PGDATABASE, user: PGUSER, password: PGPASSWORD }); -const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT || process.env.SESSION_TIMEOUT_SECONDS || '0', 10) || 0; - -// SQL helpers: users in radcheck/radreply -async function readUsersFromDb() { - const client = await pool.connect(); - try { - const q = ` - SELECT rc.username, - rc.value AS password, - EXISTS ( - SELECT 1 FROM radcheck r2 - WHERE r2.username = rc.username AND r2.attribute = 'Auth-Type' AND r2.value = 'Reject' - ) AS disabled, - COALESCE(( - SELECT rr.value FROM radreply rr - WHERE rr.username = rc.username AND rr.attribute = 'Tunnel-Private-Group-Id' - ORDER BY rr.id DESC LIMIT 1 - ), $1) AS vlan - FROM radcheck rc - WHERE rc.attribute = 'Cleartext-Password' - ORDER BY rc.username ASC`; - const { rows } = await client.query(q, [String(VLAN_ID)]); - return rows.map(r => ({ username: r.username, password: r.password, vlan: String(r.vlan), disabled: !!r.disabled })); - } finally { - client.release(); - } -} - -async function upsertUserToDb(user) { - const { username, password, vlan, disabled } = user; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - // Password - await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Cleartext-Password'", [username]); - await client.query( - "INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Cleartext-Password',':=',$2)", - [username, password] - ); - // Disabled flag - await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Auth-Type'", [username]); - if (disabled) { - await client.query( - "INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Auth-Type',':=','Reject')", - [username] - ); - } - // Reply attributes (VLAN and bandwidth) - const attrs = [ - ['Tunnel-Type', 'VLAN'], - ['Tunnel-Medium-Type', 'IEEE-802'], - ['Tunnel-Private-Group-Id', String(vlan || VLAN_ID)], - ['WISPr-Bandwidth-Max-Down', String(MAX_DOWN)], - ['WISPr-Bandwidth-Max-Up', String(MAX_UP)], - ]; - if (SESSION_TIMEOUT > 0) { - attrs.push(['Session-Timeout', String(SESSION_TIMEOUT)]); - } - await client.query( - "DELETE FROM radreply WHERE username = $1 AND attribute IN ('Tunnel-Type','Tunnel-Medium-Type','Tunnel-Private-Group-Id','WISPr-Bandwidth-Max-Down','WISPr-Bandwidth-Max-Up','Session-Timeout')", - [username] - ); - for (const [attr, val] of attrs) { - await client.query( - "INSERT INTO radreply (username, attribute, op, value) VALUES ($1,$2,':=',$3)", - [username, attr, String(val)] - ); - } - await client.query('COMMIT'); - } catch (e) { - await client.query('ROLLBACK'); - throw e; - } finally { - client.release(); - } -} - -async function deleteUserFromDb(username) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query('DELETE FROM radcheck WHERE username = $1', [username]); - await client.query('DELETE FROM radreply WHERE username = $1', [username]); - await client.query('COMMIT'); - } catch (e) { - await client.query('ROLLBACK'); - throw e; - } finally { - client.release(); - } -} - -// Helper: standard Accept with VLAN + bandwidth -function buildAcceptPayload(extra = {}) { - return { - control: { - 'Auth-Type': 'Accept', - ...extra.control, - }, - reply: { - 'Tunnel-Type': 'VLAN', - 'Tunnel-Medium-Type': 'IEEE-802', - 'Tunnel-Private-Group-Id': String(VLAN_ID), - 'WISPr-Bandwidth-Max-Down': String(MAX_DOWN), - 'WISPr-Bandwidth-Max-Up': String(MAX_UP), - ...extra.reply, - }, - }; -} - -// Normalize attributes from FreeRADIUS rlm_rest JSON -function normalizeAttributes(body = {}) { - // Newer rlm_rest may send attributes at top-level as { Attr: { type, value: [..] } } - // or under body.attributes / body.request as plain map. - const src = body.attributes || body.request || body; - const out = {}; - for (const [k, v] of Object.entries(src || {})) { - if (v && typeof v === 'object' && Array.isArray(v.value)) out[k] = v.value[0]; - else out[k] = v; - } - return out; -} - -// Authorize endpoint: FreeRADIUS rlm_rest calls this in authorize {} -app.post('/authorize', (req, res) => { - console.log('--- RADIUS Authorize Request ---'); - console.log(JSON.stringify(req.body, null, 2)); - - const attrs = normalizeAttributes(req.body); - const reply = buildAcceptPayload(); - pushRequest({ - id: Date.now() + ':' + Math.random().toString(16).slice(2), - ts: new Date().toISOString(), - type: 'authorize', - attrs, - decision: 'accept', - vlan: VLAN_ID, - bandwidth: { up: MAX_UP, down: MAX_DOWN }, - }); - return res.status(200).json(reply); -}); - -// Accounting endpoint (opcional) -app.post('/accounting', (req, res) => { - console.log('--- RADIUS Accounting ---'); - console.log(JSON.stringify(req.body, null, 2)); - const attrs = normalizeAttributes(req.body); - try { - const st = String(attrs['Acct-Status-Type'] || attrs['Acct-Status-Type*0'] || '').toUpperCase(); - const sessionId = String(attrs['Acct-Session-Id'] || ''); - const username = String(attrs['User-Name'] || ''); - if (sessionId && username) { - if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') { - activeSessions.set(sessionId, { - username, - sessionId, - nasIp: attrs['NAS-IP-Address'] || '', - nasId: attrs['NAS-Identifier'] || '', - callingStationId: attrs['Calling-Station-Id'] || '', - calledStationId: attrs['Called-Station-Id'] || '', - updatedAt: Date.now(), - }); - } else if (st === 'STOP') { - activeSessions.delete(sessionId); - } - } - } catch (e) { - console.error('accounting session update error:', e); - } - pushRequest({ - id: Date.now() + ':' + Math.random().toString(16).slice(2), - ts: new Date().toISOString(), - type: 'accounting', - attrs, - }); - return res.status(200).json({}); -}); - -// Authorize inner-tunnel (EAP): devolver Cleartext-Password para el usuario -app.post('/authorize-inner', async (req, res) => res.status(410).json({})); - -// Post-auth: return reply attributes like VLAN based on user mapping -// Post-auth events: log only (rlm_rest calls here) -app.post('/post-auth', async (req, res) => { - try { - const attrs = normalizeAttributes(req.body); - pushRequest({ - id: Date.now() + ':' + Math.random().toString(16).slice(2), - ts: new Date().toISOString(), - type: 'post-auth', - attrs, - }); - } catch (e) { - console.error('post-auth log error:', e); - } - return res.status(200).json({}); -}); - -// Users API -app.get('/api/users', async (req, res) => { - const items = await readUsersFromDb(); - res.json({ items }); -}); - -app.post('/api/users', async (req, res) => { - const { username, password, vlan, disabled } = req.body || {}; - if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password required' }); - const user = { username: String(username), password: String(password), vlan: String(vlan || VLAN_ID), disabled: !!disabled }; - await upsertUserToDb(user); - res.json({ ok: true }); -}); - -app.patch('/api/users/:username', async (req, res) => { - const uname = String(req.params.username); - const { password, vlan, disabled } = req.body || {}; - const current = (await readUsersFromDb()).find(u => u.username === uname); - if (!current) return res.status(404).json({ ok: false, error: 'not_found' }); - const next = { - username: uname, - password: password !== undefined ? String(password) : current.password, - vlan: vlan !== undefined ? String(vlan) : current.vlan, - disabled: disabled !== undefined ? !!disabled : current.disabled, - }; - await upsertUserToDb(next); - if (disabled === true) { - disconnectUserSessions(uname).catch(err => console.error('CoA disconnect error:', err)); - } - res.json({ ok: true }); -}); - -app.delete('/api/users/:username', async (req, res) => { - const uname = String(req.params.username); - await deleteUserFromDb(uname); - res.json({ ok: true }); -}); - -// API: recent requests -app.get('/api/requests', (req, res) => { - res.json({ items: requests.slice(-MAX_REQUESTS) }); -}); - -// Clear recent requests -app.delete('/api/requests', (req, res) => { - requests.length = 0; - // Notify live clients to refresh if they want - const payload = `event: clear\n` + `data: {"ok":true}\n\n`; - for (const resSse of sseClients) { - try { resSse.write(payload); } catch {} - } - res.json({ ok: true }); -}); - -// Export CSV of recent requests -app.get('/api/requests.csv', (req, res) => { - const cols = [ - 'ts','type','user','nas','calling','called','decision','vlan','bw_down','bw_up' - ]; - const lines = [cols.join(',')]; - for (const ev of requests) { - const attrs = ev.attrs || {}; - const row = [ - ev.ts || '', - ev.type || '', - attrs['User-Name'] || attrs['User-Name*0'] || '', - attrs['NAS-IP-Address'] || attrs['NAS-Identifier'] || '', - attrs['Calling-Station-Id'] || '', - attrs['Called-Station-Id'] || '', - ev.decision || '', - ev.vlan || '', - (ev.bandwidth && ev.bandwidth.down) || '', - (ev.bandwidth && ev.bandwidth.up) || '' - ]; - const esc = (v) => String(v).includes(',') || String(v).includes('"') || String(v).includes('\n') - ? '"' + String(v).replace(/"/g, '""') + '"' - : String(v); - lines.push(row.map(esc).join(',')); - } - const csv = lines.join('\n'); - const ts = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0]; - res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="radius-events-${ts}.csv"`); - res.send(csv); -}); - -// SSE stream for live updates -app.get('/events', (req, res) => { - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.flushHeaders?.(); - // send a hello event - res.write(`event: hello\n`); - res.write(`data: {"ok":true}\n\n`); - // send initial status - res.write(`event: status\n`); - res.write(`data: ${JSON.stringify({ radius_reloading: radiusReloading })}\n\n`); - sseClients.add(res); - req.on('close', () => sseClients.delete(res)); -}); - -// Root: serve dashboard -app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); -}); - -// Self-test: send a RADIUS Access-Request to FreeRADIUS -async function sendRadiusSelfTest() { - return new Promise((resolve, reject) => { - try { - const packet = radius.encode({ - code: 'Access-Request', - secret: RADIUS_SECRET, - attributes: { - 'User-Name': 'selftest-node', - 'NAS-Identifier': 'node-dashboard', - 'Calling-Station-Id': '001122334455', - }, - }); - const client = dgram.createSocket('udp4'); - const started = Date.now(); - const timeout = setTimeout(() => { - client.close(); - reject(new Error('timeout')); - }, 4000); - client.on('message', (msg) => { - clearTimeout(timeout); - client.close(); - const res = radius.decode({ packet: msg, secret: RADIUS_SECRET }); - resolve({ - code: res.code, - rtt_ms: Date.now() - started, - }); - }); - client.send(packet, 0, packet.length, RADIUS_AUTH_PORT, RADIUS_HOST, (err) => { - if (err) { - clearTimeout(timeout); - client.close(); - reject(err); - } - }); - } catch (e) { - reject(e); - } - }); -} - -// Send RADIUS Disconnect-Request (CoA) to NAS (UDP 3799) -async function sendDisconnectRequest({ nasIp, username, sessionId, callingStationId, nasId }) { - if (!nasIp) throw new Error('NAS IP required for Disconnect-Request'); - const packet = radius.encode({ - code: 'Disconnect-Request', - secret: RADIUS_SECRET, - attributes: { - 'User-Name': username, - 'Acct-Session-Id': sessionId, - 'Calling-Station-Id': callingStationId || undefined, - 'NAS-IP-Address': nasIp, - 'NAS-Identifier': nasId || undefined, - }, - }); - return new Promise((resolve, reject) => { - const client = dgram.createSocket('udp4'); - const timeout = setTimeout(() => { - client.close(); - reject(new Error('CoA timeout')); - }, 3000); - client.on('message', (msg) => { - clearTimeout(timeout); - client.close(); - try { - const res = radius.decode({ packet: msg, secret: RADIUS_SECRET }); - resolve({ code: res.code }); - } catch (e) { - resolve({ code: 'unknown' }); - } - }); - client.send(packet, 0, packet.length, 3799, nasIp, (err) => { - if (err) { - clearTimeout(timeout); - client.close(); - reject(err); - } - }); - }); -} - -async function disconnectUserSessions(username) { - const targets = []; - for (const sess of activeSessions.values()) { - if (sess.username === username && sess.nasIp) targets.push(sess); - } - if (targets.length === 0) return; - for (const sess of targets) { - try { - const result = await sendDisconnectRequest({ - nasIp: sess.nasIp, - username: sess.username, - sessionId: sess.sessionId, - callingStationId: sess.callingStationId, - nasId: sess.nasId, - }); - pushRequest({ - id: Date.now() + ':' + Math.random().toString(16).slice(2), - ts: new Date().toISOString(), - type: 'coa-disconnect', - attrs: { - 'User-Name': sess.username, - 'NAS-IP-Address': sess.nasIp, - 'Acct-Session-Id': sess.sessionId, - 'Calling-Station-Id': sess.callingStationId, - 'result': result.code, - }, - }); - } catch (e) { - pushRequest({ - id: Date.now() + ':' + Math.random().toString(16).slice(2), - ts: new Date().toISOString(), - type: 'coa-disconnect', - attrs: { - 'User-Name': sess.username, - 'NAS-IP-Address': sess.nasIp, - 'Acct-Session-Id': sess.sessionId, - 'Calling-Station-Id': sess.callingStationId, - 'error': String(e?.message || e), - }, - }); - } - } -} - -app.post('/test/radius', async (req, res) => { - try { - const result = await sendRadiusSelfTest(); - pushRequest({ - id: Date.now() + ':' + Math.random().toString(16).slice(2), - ts: new Date().toISOString(), - type: 'selftest', - attrs: { 'User-Name': 'selftest-node' }, - decision: result.code, - }); - res.json({ ok: true, result }); - } catch (err) { - pushRequest({ - id: Date.now() + ':' + Math.random().toString(16).slice(2), - ts: new Date().toISOString(), - type: 'selftest', - attrs: { 'User-Name': 'selftest-node' }, - decision: 'error', - error: String(err && err.message || err), - }); - res.status(500).json({ ok: false, error: String(err && err.message || err) }); - } -}); +import { createApp } from './src/app.js'; +const app = createApp(); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Node RADIUS REST API listening on :${port}`); }); + diff --git a/node-api/src/app.js b/node-api/src/app.js new file mode 100644 index 0000000..6243309 --- /dev/null +++ b/node-api/src/app.js @@ -0,0 +1,29 @@ +import express from 'express'; +import morgan from 'morgan'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import apiRouter from './routes/api.js'; +import radiusRouter from './routes/radius.js'; + +export function createApp() { + const app = express(); + app.use(express.json()); + app.use(morgan('dev')); + + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + app.use(express.static(path.join(__dirname, '..', 'public'))); + + // RADIUS hooks (rlm_rest) + app.use('/', radiusRouter); + + // REST API + app.use('/api', apiRouter); + + app.get('/', (_req, res) => { + res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); + }); + + return app; +} + diff --git a/node-api/src/config/env.js b/node-api/src/config/env.js new file mode 100644 index 0000000..a9bd17a --- /dev/null +++ b/node-api/src/config/env.js @@ -0,0 +1,15 @@ +export const VLAN_ID = process.env.VLAN_ID || '2'; +export const MAX_UP = process.env.MAX_UP || '10000000'; +export const MAX_DOWN = process.env.MAX_DOWN || '10000000'; +export const MAX_REQUESTS = parseInt(process.env.MAX_REQUESTS || '200', 10); +export const RADIUS_HOST = process.env.RADIUS_HOST || 'freeradius'; +export const RADIUS_AUTH_PORT = parseInt(process.env.RADIUS_AUTH_PORT || '1812', 10); +export const RADIUS_SECRET = process.env.RADIUS_SECRET || process.env.RADIUS_SHARED_SECRET || 'tamosbien'; + +export const PGHOST = process.env.PGHOST || 'postgres'; +export const PGPORT = parseInt(process.env.PGPORT || '5432', 10); +export const PGDATABASE = process.env.PGDATABASE || 'radius'; +export const PGUSER = process.env.PGUSER || 'radius'; +export const PGPASSWORD = process.env.PGPASSWORD || 'radius'; +export const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT || process.env.SESSION_TIMEOUT_SECONDS || '0', 10) || 0; + diff --git a/node-api/src/routes/api.js b/node-api/src/routes/api.js new file mode 100644 index 0000000..97a3873 --- /dev/null +++ b/node-api/src/routes/api.js @@ -0,0 +1,92 @@ +import { Router } from 'express'; +import { VLAN_ID } from '../config/env.js'; +import { clearRequests, getRecentRequests, registerSse } from '../sse.js'; +import { deleteUserFromDb, readUsersFromDb, upsertUserToDb } from '../services/db.js'; +import { disconnectUserSessions } from '../services/radius.js'; + +const router = Router(); + +// Users +router.get('/users', async (_req, res) => { + const items = await readUsersFromDb(); + res.json({ items }); +}); + +router.post('/users', async (req, res) => { + const { username, password, vlan, disabled } = req.body || {}; + if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password required' }); + const user = { username: String(username), password: String(password), vlan: String(vlan || VLAN_ID), disabled: !!disabled }; + await upsertUserToDb(user); + res.json({ ok: true }); +}); + +router.patch('/users/:username', async (req, res) => { + const uname = String(req.params.username); + const { password, vlan, disabled } = req.body || {}; + const current = (await readUsersFromDb()).find(u => u.username === uname); + if (!current) return res.status(404).json({ ok: false, error: 'not_found' }); + const next = { + username: uname, + password: password !== undefined ? String(password) : current.password, + vlan: vlan !== undefined ? String(vlan) : current.vlan, + disabled: disabled !== undefined ? !!disabled : current.disabled, + }; + await upsertUserToDb(next); + if (disabled === true) { + disconnectUserSessions(uname).catch(err => console.error('CoA disconnect error:', err)); + } + res.json({ ok: true }); +}); + +router.delete('/users/:username', async (req, res) => { + const uname = String(req.params.username); + await deleteUserFromDb(uname); + res.json({ ok: true }); +}); + +// Requests +router.get('/requests', (_req, res) => { + res.json({ items: getRecentRequests() }); +}); + +router.delete('/requests', (_req, res) => { + clearRequests(); + res.json({ ok: true }); +}); + +router.get('/requests.csv', (_req, res) => { + const items = getRecentRequests(); + const cols = ['ts','type','user','nas','calling','called','decision','vlan','bw_down','bw_up']; + const lines = [cols.join(',')]; + for (const ev of items) { + const attrs = ev.attrs || {}; + const row = [ + ev.ts || '', + ev.type || '', + attrs['User-Name'] || attrs['User-Name*0'] || '', + attrs['NAS-IP-Address'] || attrs['NAS-Identifier'] || '', + attrs['Calling-Station-Id'] || '', + attrs['Called-Station-Id'] || '', + ev.decision || '', + ev.vlan || '', + (ev.bandwidth && ev.bandwidth.down) || '', + (ev.bandwidth && ev.bandwidth.up) || '' + ]; + const esc = (v) => String(v).includes(',') || String(v).includes('"') || String(v).includes('\n') + ? '"' + String(v).replace(/"/g, '""') + '"' + : String(v); + lines.push(row.map(esc).join(',')); + } + const csv = lines.join('\n'); + const ts = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0]; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="radius-events-${ts}.csv"`); + res.send(csv); +}); + +// SSE events +router.get('/events', (req, res) => { + registerSse(req, res, {}); +}); + +export default router; diff --git a/node-api/src/routes/radius.js b/node-api/src/routes/radius.js new file mode 100644 index 0000000..232495d --- /dev/null +++ b/node-api/src/routes/radius.js @@ -0,0 +1,94 @@ +import { Router } from 'express'; +import { VLAN_ID } from '../config/env.js'; +import { buildAcceptPayload, normalizeAttributes } from '../utils/attrs.js'; +import { pushRequest } from '../sse.js'; +import { activeSessions, sendRadiusSelfTest } from '../services/radius.js'; + +const router = Router(); + +router.post('/authorize', (req, res) => { + const attrs = normalizeAttributes(req.body); + const reply = buildAcceptPayload(); + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'authorize', + attrs, + decision: 'accept', + vlan: VLAN_ID, + }); + return res.status(200).json(reply); +}); + +router.post('/accounting', (req, res) => { + const attrs = normalizeAttributes(req.body); + try { + const st = String(attrs['Acct-Status-Type'] || attrs['Acct-Status-Type*0'] || '').toUpperCase(); + const sessionId = String(attrs['Acct-Session-Id'] || ''); + const username = String(attrs['User-Name'] || ''); + if (sessionId && username) { + if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') { + activeSessions.set(sessionId, { + username, + sessionId, + nasIp: attrs['NAS-IP-Address'] || '', + nasId: attrs['NAS-Identifier'] || '', + callingStationId: attrs['Calling-Station-Id'] || '', + calledStationId: attrs['Called-Station-Id'] || '', + updatedAt: Date.now(), + }); + } else if (st === 'STOP') { + activeSessions.delete(sessionId); + } + } + } catch {} + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'accounting', + attrs, + }); + return res.status(200).json({}); +}); + +router.post('/authorize-inner', async (_req, res) => res.status(410).json({})); + +router.post('/post-auth', async (req, res) => { + try { + const attrs = normalizeAttributes(req.body); + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'post-auth', + attrs, + }); + } catch {} + return res.status(200).json({}); +}); + +router.post('/test/radius', async (_req, res) => { + try { + const result = await sendRadiusSelfTest(); + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'selftest', + attrs: { 'User-Name': 'selftest-node' }, + decision: result.code, + }); + res.json({ ok: true, result }); + } catch (err) { + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'selftest', + attrs: { 'User-Name': 'selftest-node' }, + decision: 'error', + error: String(err && err.message || err), + }); + res.status(500).json({ ok: false, error: String(err && err.message || err) }); + } +}); + +export default router; + diff --git a/node-api/src/services/db.js b/node-api/src/services/db.js new file mode 100644 index 0000000..33ad868 --- /dev/null +++ b/node-api/src/services/db.js @@ -0,0 +1,92 @@ +import pkgPg from 'pg'; +import { PGDATABASE, PGHOST, PGPASSWORD, PGPORT, PGUSER, SESSION_TIMEOUT, VLAN_ID, MAX_DOWN, MAX_UP } from '../config/env.js'; + +const { Pool } = pkgPg; +export const pool = new Pool({ host: PGHOST, port: PGPORT, database: PGDATABASE, user: PGUSER, password: PGPASSWORD }); + +export async function readUsersFromDb() { + const client = await pool.connect(); + try { + const q = ` + SELECT rc.username, + rc.value AS password, + EXISTS ( + SELECT 1 FROM radcheck r2 + WHERE r2.username = rc.username AND r2.attribute = 'Auth-Type' AND r2.value = 'Reject' + ) AS disabled, + COALESCE(( + SELECT rr.value FROM radreply rr + WHERE rr.username = rc.username AND rr.attribute = 'Tunnel-Private-Group-Id' + ORDER BY rr.id DESC LIMIT 1 + ), $1) AS vlan + FROM radcheck rc + WHERE rc.attribute = 'Cleartext-Password' + ORDER BY rc.username ASC`; + const { rows } = await client.query(q, [String(VLAN_ID)]); + return rows.map(r => ({ username: r.username, password: r.password, vlan: String(r.vlan), disabled: !!r.disabled })); + } finally { + client.release(); + } +} + +export async function upsertUserToDb(user) { + const { username, password, vlan, disabled } = user; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Cleartext-Password'", [username]); + await client.query( + "INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Cleartext-Password',':=',$2)", + [username, password] + ); + await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Auth-Type'", [username]); + if (disabled) { + await client.query( + "INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Auth-Type',':=','Reject')", + [username] + ); + } + const attrs = [ + ['Tunnel-Type', 'VLAN'], + ['Tunnel-Medium-Type', 'IEEE-802'], + ['Tunnel-Private-Group-Id', String(vlan || VLAN_ID)], + ['WISPr-Bandwidth-Max-Down', String(MAX_DOWN)], + ['WISPr-Bandwidth-Max-Up', String(MAX_UP)], + ]; + if (SESSION_TIMEOUT > 0) { + attrs.push(['Session-Timeout', String(SESSION_TIMEOUT)]); + } + await client.query( + "DELETE FROM radreply WHERE username = $1 AND attribute IN ('Tunnel-Type','Tunnel-Medium-Type','Tunnel-Private-Group-Id','WISPr-Bandwidth-Max-Down','WISPr-Bandwidth-Max-Up','Session-Timeout')", + [username] + ); + for (const [attr, val] of attrs) { + await client.query( + "INSERT INTO radreply (username, attribute, op, value) VALUES ($1,$2,':=',$3)", + [username, attr, String(val)] + ); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +export async function deleteUserFromDb(username) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('DELETE FROM radcheck WHERE username = $1', [username]); + await client.query('DELETE FROM radreply WHERE username = $1', [username]); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + diff --git a/node-api/src/services/radius.js b/node-api/src/services/radius.js new file mode 100644 index 0000000..513cfc8 --- /dev/null +++ b/node-api/src/services/radius.js @@ -0,0 +1,127 @@ +import dgram from 'dgram'; +import radius from 'radius'; +import { RADIUS_AUTH_PORT, RADIUS_HOST, RADIUS_SECRET } from '../config/env.js'; +import { pushRequest } from '../sse.js'; + +export const activeSessions = new Map(); + +export async function sendRadiusSelfTest() { + return new Promise((resolve, reject) => { + try { + const packet = radius.encode({ + code: 'Access-Request', + secret: RADIUS_SECRET, + attributes: { + 'User-Name': 'selftest-node', + 'NAS-Identifier': 'node-dashboard', + 'Calling-Station-Id': '001122334455', + }, + }); + const client = dgram.createSocket('udp4'); + const started = Date.now(); + const timeout = setTimeout(() => { + client.close(); + reject(new Error('timeout')); + }, 4000); + client.on('message', (msg) => { + clearTimeout(timeout); + client.close(); + const res = radius.decode({ packet: msg, secret: RADIUS_SECRET }); + resolve({ code: res.code, rtt_ms: Date.now() - started }); + }); + client.send(packet, 0, packet.length, RADIUS_AUTH_PORT, RADIUS_HOST, (err) => { + if (err) { + clearTimeout(timeout); + client.close(); + reject(err); + } + }); + } catch (e) { + reject(e); + } + }); +} + +export async function sendDisconnectRequest({ nasIp, username, sessionId, callingStationId, nasId }) { + if (!nasIp) throw new Error('NAS IP required for Disconnect-Request'); + const packet = radius.encode({ + code: 'Disconnect-Request', + secret: RADIUS_SECRET, + attributes: { + 'User-Name': username, + 'Acct-Session-Id': sessionId, + 'Calling-Station-Id': callingStationId || undefined, + 'NAS-IP-Address': nasIp, + 'NAS-Identifier': nasId || undefined, + }, + }); + return new Promise((resolve, reject) => { + const client = dgram.createSocket('udp4'); + const timeout = setTimeout(() => { + client.close(); + reject(new Error('CoA timeout')); + }, 3000); + client.on('message', (msg) => { + clearTimeout(timeout); + client.close(); + try { + const res = radius.decode({ packet: msg, secret: RADIUS_SECRET }); + resolve({ code: res.code }); + } catch (e) { + resolve({ code: 'unknown' }); + } + }); + client.send(packet, 0, packet.length, 3799, nasIp, (err) => { + if (err) { + clearTimeout(timeout); + client.close(); + reject(err); + } + }); + }); +} + +export async function disconnectUserSessions(username) { + const targets = []; + for (const sess of activeSessions.values()) { + if (sess.username === username && sess.nasIp) targets.push(sess); + } + if (targets.length === 0) return; + for (const sess of targets) { + try { + const result = await sendDisconnectRequest({ + nasIp: sess.nasIp, + username: sess.username, + sessionId: sess.sessionId, + callingStationId: sess.callingStationId, + nasId: sess.nasId, + }); + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'coa-disconnect', + attrs: { + 'User-Name': sess.username, + 'NAS-IP-Address': sess.nasIp, + 'Acct-Session-Id': sess.sessionId, + 'Calling-Station-Id': sess.callingStationId, + 'result': result.code, + }, + }); + } catch (e) { + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'coa-disconnect', + attrs: { + 'User-Name': sess.username, + 'NAS-IP-Address': sess.nasIp, + 'Acct-Session-Id': sess.sessionId, + 'Calling-Station-Id': sess.callingStationId, + 'error': String(e?.message || e), + }, + }); + } + } +} + diff --git a/node-api/src/sse.js b/node-api/src/sse.js new file mode 100644 index 0000000..225b864 --- /dev/null +++ b/node-api/src/sse.js @@ -0,0 +1,48 @@ +import { MAX_REQUESTS } from './config/env.js'; + +const sseClients = new Set(); +const requests = []; + +export function registerSse(req, res, initialStatus = {}) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders?.(); + res.write(`event: hello\n`); + res.write(`data: {"ok":true}\n\n`); + if (initialStatus && Object.keys(initialStatus).length) { + res.write(`event: status\n`); + res.write(`data: ${JSON.stringify(initialStatus)}\n\n`); + } + sseClients.add(res); + req.on('close', () => sseClients.delete(res)); +} + +export function pushRequest(rec) { + requests.push(rec); + while (requests.length > MAX_REQUESTS) requests.shift(); + const payload = `data: ${JSON.stringify(rec)}\n\n`; + for (const res of sseClients) { + try { res.write(payload); } catch { /* ignore */ } + } +} + +export function broadcastStatus(payload) { + const ev = `event: status\n` + `data: ${JSON.stringify(payload)}\n\n`; + for (const res of sseClients) { + try { res.write(ev); } catch { /* ignore */ } + } +} + +export function clearRequests() { + requests.length = 0; + const payload = `event: clear\n` + `data: {"ok":true}\n\n`; + for (const res of sseClients) { + try { res.write(payload); } catch { /* ignore */ } + } +} + +export function getRecentRequests() { + return requests.slice(-MAX_REQUESTS); +} + diff --git a/node-api/src/utils/attrs.js b/node-api/src/utils/attrs.js new file mode 100644 index 0000000..77b7a43 --- /dev/null +++ b/node-api/src/utils/attrs.js @@ -0,0 +1,29 @@ +import { MAX_DOWN, MAX_UP, VLAN_ID } from '../config/env.js'; + +export function buildAcceptPayload(extra = {}) { + return { + control: { + 'Auth-Type': 'Accept', + ...(extra.control || {}), + }, + reply: { + 'Tunnel-Type': 'VLAN', + 'Tunnel-Medium-Type': 'IEEE-802', + 'Tunnel-Private-Group-Id': String(VLAN_ID), + 'WISPr-Bandwidth-Max-Down': String(MAX_DOWN), + 'WISPr-Bandwidth-Max-Up': String(MAX_UP), + ...(extra.reply || {}), + }, + }; +} + +export function normalizeAttributes(body = {}) { + const src = body.attributes || body.request || body; + const out = {}; + for (const [k, v] of Object.entries(src || {})) { + if (v && typeof v === 'object' && Array.isArray(v.value)) out[k] = v.value[0]; + else out[k] = v; + } + return out; +} + diff --git a/package.json b/package.json index f26a5bb..bf2e9f6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { + "predev": "npm --prefix frontend install && npm --prefix frontend run build", "dev": "docker compose up -d --build", + "prerestart": "npm --prefix frontend install && npm --prefix frontend run build", "restart": "docker compose up -d --build --force-recreate", "hot": "docker compose up -d node", "logs": "docker compose logs -f",