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 @@
+
+
+ RADIUS Dashboard
+
+
+
Usuarios
+
+
Cargando usuarios…
+
+
+
+ | Usuario |
+ VLAN |
+ Estado |
+ |
+
+
+
+
+ | {{ u.username }} |
+ {{ u.vlan }} |
+ {{ u.disabled ? 'deshabilitado' : 'activo' }} |
+
+
+
+ |
+
+
+
+
+
+
Eventos
+
+
Cargando eventos…
+
+
+
{{ ev.ts }} — {{ ev.type }}
+
+ User: {{ ev.attrs['User-Name'] || ev.attrs['User-Name*0'] }}
+ — NAS: {{ ev.attrs['NAS-IP-Address'] }}
+ — STA: {{ ev.attrs['Calling-Station-Id'] }}
+
+
Decision: {{ ev.decision }}
+
Error: {{ ev.error }}
+
+
+
+
+
+
+
+
+
+
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",