From ce1710dbd2bc37ed3fc5faca7e2560a0cd226bf6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Nov 2025 20:38:30 +0100 Subject: [PATCH] initial commit --- .dockerignore | 17 ++ .gitignore | 41 +++ Dockerfile | 38 +++ LICENSE | 7 + README.md | 65 ++++- bun.lock | 247 ++++++++++++++++++ config-schema.json | 1 + package.json | 23 ++ src/clients/mqtt/mqtt-client.schema.ts | 8 + src/clients/mqtt/mqtt-client.ts | 12 + .../nano-kvm/nano-kvm-client.schema.ts | 99 +++++++ src/clients/nano-kvm/nano-kvm-client.ts | 90 +++++++ src/clients/nano-kvm/nano-kvm.service.ts | 64 +++++ src/config.schema.ts | 9 + src/encryption.ts | 47 ++++ src/index.ts | 68 +++++ src/listener.ts | 40 +++ src/logger.ts | 5 + src/publisher.ts | 128 +++++++++ src/scripts/generate-config-json-schema.ts | 7 + tsconfig.json | 34 +++ tsdown.config.ts | 6 + 22 files changed, 1055 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 bun.lock create mode 100644 config-schema.json create mode 100644 package.json create mode 100644 src/clients/mqtt/mqtt-client.schema.ts create mode 100644 src/clients/mqtt/mqtt-client.ts create mode 100644 src/clients/nano-kvm/nano-kvm-client.schema.ts create mode 100644 src/clients/nano-kvm/nano-kvm-client.ts create mode 100644 src/clients/nano-kvm/nano-kvm.service.ts create mode 100644 src/config.schema.ts create mode 100644 src/encryption.ts create mode 100644 src/index.ts create mode 100644 src/listener.ts create mode 100644 src/logger.ts create mode 100644 src/publisher.ts create mode 100644 src/scripts/generate-config-json-schema.ts create mode 100644 tsconfig.json create mode 100644 tsdown.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3f3506e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +Makefile +helm-charts +.env +.editorconfig +.idea +coverage* +*.client.json +src/local.config.ts \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2887857 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +*/**/local.* + +# The client configs for this application +*.client.json + +docker-compose.local.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..054b363 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# use the official Bun image +# see all versions at https://hub.docker.com/r/oven/bun/tags +FROM oven/bun:1 AS base +WORKDIR /usr/src/app + +# install dependencies into temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lock /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lock /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production +#RUN bun test +RUN bun run build + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/dist/index.mjs . +COPY --from=prerelease /usr/src/app/package.json . + +# run the app +USER bun +#EXPOSE 3000/tcp +ENTRYPOINT [ "bun", "run", "index.mjs" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b080ffa --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 Enrico Bühler + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 6c99b8c..9ee58fe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,66 @@ # nanokvm-mqtt -Exposes NanoKVM API via MQTT for use with home assistant \ No newline at end of file +Exposes NanoKVM API via MQTT with support for home assistant autodiscovery/config. + +## Implementation + +This project is built with TypeScript and enforces runtime type safety where possible by using [zod](https://zod.dev). + +## Disclaimer + +Please read the License and understand that this program comes with **no warranties or guarantees**. + +**Critical Security Warning:** Exposing a KVM (Keyboard, Video, Mouse) device over a network creates significant security risks, as it provides direct access to connected systems. Carefully review our security advisories below before deployment. + +## Usage + +1. Clone the repository +2. Create one or multiple client configs like ```xxx.client.json``` +3. **Option A (recommended)** Use docker-compose like in the provided example ```docker-compose.example.yml``` +3. **Option B** Install bun and run via ```bun run build && bun run start``` +4. Your NanoKVM(s) should now show up in home assistant via autodiscovery + +## Security Notices + +### NanoKVM + +**Critical vulnerability:** The NanoKVM firmware currently uses a hardcoded secret key for authentication. +Thats only one of the many security flaws. +By default, this project includes the known hardcoded secret for compatibility. + +We strongly recommend blocking every connection going in and out of the NanoKVM by default, +and only allow as narrow access as possible to the web server (port 80) with return traffic. +Its also recommended to only enable SSH when you need it. + +### Additional Security Recommendations + +- **TLS/SSL:** Use encrypted MQTT connections (mqtts://) with valid certificates +- **MQTT Authentication:** Enable username/password authentication on your MQTT broker +- **Access Control:** Implement MQTT ACLs (Access Control Lists) to restrict topic access +- **Firewall Rules:** Block external access; only allow connections from trusted IPs +- **Regular Updates:** Monitor for NanoKVM firmware and dependency updates + +### Usage with Home Assistant + +**Think carefully before integrating this with Home Assistant**, especially if: + +- Your Home Assistant instance is publicly accessible +- You use cloud-based integrations or remote access features +- Multiple users have access to your Home Assistant dashboard + +**Recommended mitigations:** + +- Keep Home Assistant on a private network only +- Use VPN access instead of port forwarding +- Monitor access logs regularly +- Consider if you truly need KVM control through Home Assistant + +### Known Risks + +Potential attack vectors include: + +- Unauthorized access to connected computers/servers +- Keystroke injection and command execution +- Screen capture and information disclosure +- BIOS/firmware manipulation on connected systems +- Lateral movement within your network diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ee7b0d1 --- /dev/null +++ b/bun.lock @@ -0,0 +1,247 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "nanokvm-mqtt", + "dependencies": { + "mqtt": "^5.14.1", + "tsdown": "^0.16.4", + "tslog": "^4.10.2", + "zod": "^4.1.12", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@oxc-project/runtime": ["@oxc-project/runtime@0.97.0", "", {}, "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w=="], + + "@oxc-project/types": ["@oxc-project/types@0.97.0", "", {}, "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ=="], + + "@quansync/fs": ["@quansync/fs@0.1.5", "", { "dependencies": { "quansync": "^0.2.11" } }, "sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.50", "", { "os": "android", "cpu": "arm64" }, "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.50", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.50", "", { "os": "darwin", "cpu": "x64" }, "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.50", "", { "os": "freebsd", "cpu": "x64" }, "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50", "", { "os": "linux", "cpu": "arm" }, "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50", "", { "os": "linux", "cpu": "arm64" }, "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.50", "", { "os": "linux", "cpu": "arm64" }, "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.50", "", { "os": "linux", "cpu": "x64" }, "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.50", "", { "os": "linux", "cpu": "x64" }, "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.50", "", { "os": "none", "cpu": "arm64" }, "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.50", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.7" }, "cpu": "none" }, "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50", "", { "os": "win32", "cpu": "arm64" }, "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA=="], + + "@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50", "", { "os": "win32", "cpu": "ia32" }, "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.50", "", { "os": "win32", "cpu": "x64" }, "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.50", "", {}, "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/react": ["@types/react@19.2.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A=="], + + "@types/readable-stream": ["@types/readable-stream@4.0.22", "", { "dependencies": { "@types/node": "*" } }, "sha512-/FFhJpfCLAPwAcN3mFycNUa77ddnr8jTgF5VmSNetaemWB2cIlfCA9t0YTM3JAT0wOcv8D4tjPo7pkDhK3EJIg=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "birpc": ["birpc@2.8.0", "", {}, "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw=="], + + "bl": ["bl@6.1.4", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-ZV/9asSuknOExbM/zPPA8z00lc1ihPKWaStHkkQrxHNeYx+yY+TmF+v80dpv2G0mv3HVXBu7ryoAsxbFFhf4eg=="], + + "broker-factory": ["broker-factory@3.1.10", "", { "dependencies": { "@babel/runtime": "^7.28.4", "fast-unique-numbers": "^9.0.24", "tslib": "^2.8.1", "worker-factory": "^7.0.46" } }, "sha512-BzqK5GYFhvVFvO13uzPN0SCiOsOQuhMUbsGvTXDJMA2/N4GvIlFdxEuueE+60Zk841bBU5G3+fl2cqYEo0wgGg=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="], + + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "fast-unique-numbers": ["fast-unique-numbers@9.0.24", "", { "dependencies": { "@babel/runtime": "^7.28.4", "tslib": "^2.8.1" } }, "sha512-Dv0BYn4waOWse94j16rsZ5w/0zoaCa74O3q6IZjMqaXbtT92Q+Sb6pPk+phGzD8Xh+nueQmSRI3tSCaHKidzKw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mqtt": ["mqtt@5.14.1", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw=="], + + "mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="], + + "obug": ["obug@1.0.0", "", { "peerDependencies": { "ms": "^2.0.0" }, "optionalPeers": ["ms"] }, "sha512-WKcS43Yl6YPJekid7KiRdT6CHMSmYWVfJiSFbTaGxWQlC+cEBPxHa9jR1uS2cMiQmXd8Hsa2ipAKErQ/GLhSpg=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rolldown": ["rolldown@1.0.0-beta.50", "", { "dependencies": { "@oxc-project/types": "=0.97.0", "@rolldown/pluginutils": "1.0.0-beta.50" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-x64": "1.0.0-beta.50", "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A=="], + + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.17.7", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ast-kit": "^2.2.0", "birpc": "^2.8.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.0", "magic-string": "^0.30.21", "obug": "^1.0.0" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-beta.44", "typescript": "^5.0.0", "vue-tsc": "~3.1.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-ZGgXMhzCItmznNzbJlTcC/KdM6bIwcZoYUykJ2q14HOGvnMhnl2RXU+XrIrdjA2Hyzq3nWqDH7qWaM5a4uCVnw=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "tsdown": ["tsdown@0.16.4", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "diff": "^8.0.2", "empathic": "^2.0.0", "hookable": "^5.5.3", "obug": "^1.0.0", "rolldown": "1.0.0-beta.50", "rolldown-plugin-dts": "^0.17.7", "semver": "^7.7.3", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.4.1", "unrun": "^0.2.9" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@vitejs/devtools": "^0.0.0-alpha.17", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["@arethetypeswrong/core", "@vitejs/devtools", "publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-tdhy+EQpZSVrVkDRnjKEKVfh1git2HrliGp3SylRNg7kk+lOx3SvT7NLakKX5grPAg8WrZrXWLPUrCegWupqgg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tslog": ["tslog@4.10.2", "", {}, "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unconfig-core": ["unconfig-core@7.4.1", "", { "dependencies": { "@quansync/fs": "^0.1.5", "quansync": "^0.2.11" } }, "sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unrun": ["unrun@0.2.9", "", { "dependencies": { "@oxc-project/runtime": "^0.97.0", "rolldown": "1.0.0-beta.50" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-Cta7uGK/08OqH2O0HLYXs1AfIBp2+2v11ZoeAIqJLUCb9CN+7uxj+CldHCzqAw30b8MJEmWe+BFgK2sl4lJXlw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "worker-factory": ["worker-factory@7.0.46", "", { "dependencies": { "@babel/runtime": "^7.28.4", "fast-unique-numbers": "^9.0.24", "tslib": "^2.8.1" } }, "sha512-Sr1hq2FMgNa04UVhYQacsw+i58BtMimzDb4+CqYphZ97OfefRpURu0UZ+JxMr/H36VVJBfuVkxTK7MytsanC3w=="], + + "worker-timers": ["worker-timers@8.0.25", "", { "dependencies": { "@babel/runtime": "^7.28.4", "tslib": "^2.8.1", "worker-timers-broker": "^8.0.11", "worker-timers-worker": "^9.0.11" } }, "sha512-X7Z5dmM6PlrEnaadtFQOyXHGD/IysPA3HZzaC2koqsU1VI+RvyGmjiiLiUBQixK8PH5R7ilkOzZupWskNRaXmA=="], + + "worker-timers-broker": ["worker-timers-broker@8.0.11", "", { "dependencies": { "@babel/runtime": "^7.28.4", "broker-factory": "^3.1.10", "fast-unique-numbers": "^9.0.24", "tslib": "^2.8.1", "worker-timers-worker": "^9.0.11" } }, "sha512-uwhxKru8BI9m2tsogxr2fB6POZ8LB2xH+Pu3R0mvQnAZLPgLD6K3IX4LNKPTEgTJ/j5VsuQPB+gLI1NBNKkPlg=="], + + "worker-timers-worker": ["worker-timers-worker@9.0.11", "", { "dependencies": { "@babel/runtime": "^7.28.4", "tslib": "^2.8.1", "worker-factory": "^7.0.46" } }, "sha512-pArb5xtgHWImYpXhjg1OFv7JFG0ubmccb73TFoXHXjG830fFj+16N57q9YeBnZX52dn+itRrMoJZ9HaZBVzDaA=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + + "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + } +} diff --git a/config-schema.json b/config-schema.json new file mode 100644 index 0000000..70b5ed2 --- /dev/null +++ b/config-schema.json @@ -0,0 +1 @@ +{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"nanoKvm":{"type":"object","properties":{"host":{"type":"string"},"username":{"type":"string"},"password":{"type":"string"},"passwordSecret":{"default":"nanokvm-sipeed-2024","type":"string"},"protocol":{"default":"http","type":"string","enum":["http","https"]},"token":{"type":"string"}},"required":["host","username","password","passwordSecret","protocol"],"additionalProperties":false},"mqtt":{"type":"object","properties":{"password":{"type":"string"},"user":{"type":"string"},"host":{"type":"string"},"port":{"type":"number"}},"required":["host","port"],"additionalProperties":false}},"required":["nanoKvm","mqtt"],"additionalProperties":false} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..86302d1 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "nanokvm-mqtt", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun --watch ./src/index.ts", + "build": "tsdown", + "start": "bun run ./dist/index.mjs" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "mqtt": "^5.14.1", + "tsdown": "^0.16.4", + "tslog": "^4.10.2", + "zod": "^4.1.12" + } +} diff --git a/src/clients/mqtt/mqtt-client.schema.ts b/src/clients/mqtt/mqtt-client.schema.ts new file mode 100644 index 0000000..06e76eb --- /dev/null +++ b/src/clients/mqtt/mqtt-client.schema.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const MQTTClientOptions = z.object({ + password: z.string().optional(), + user: z.string().optional(), + host: z.string(), + port: z.number(), +}); diff --git a/src/clients/mqtt/mqtt-client.ts b/src/clients/mqtt/mqtt-client.ts new file mode 100644 index 0000000..5344fbe --- /dev/null +++ b/src/clients/mqtt/mqtt-client.ts @@ -0,0 +1,12 @@ +import mqtt from "mqtt"; +import type z from "zod"; +import type { MQTTClientOptions } from "./mqtt-client.schema"; + +export async function getMqttClient( + options: z.output, +) { + return await mqtt.connectAsync(`mqtt://${options.host}:${options.port}`, { + password: options.password, + username: options.user, + }); +} diff --git a/src/clients/nano-kvm/nano-kvm-client.schema.ts b/src/clients/nano-kvm/nano-kvm-client.schema.ts new file mode 100644 index 0000000..05aedcb --- /dev/null +++ b/src/clients/nano-kvm/nano-kvm-client.schema.ts @@ -0,0 +1,99 @@ +import z from "zod"; + +const ResponseBody = z.object({ + code: z.number(), + msg: z.string(), + data: z.any(), +}); + +const CODE_SUCCESS = z.literal(0); + +export const NanoKVMClientOptions = z.object({ + /** host of nanokvm */ + host: z.string(), + /** the nanokvm username */ + username: z.string(), + /** the nanokvm password */ + password: z.string(), + /** password secret, optional. + * only needs to be set if a different secret is set on the nanokvm for more secure password encryption. */ + passwordSecret: z.string().default("nanokvm-sipeed-2024"), + /** https isn't usable by default so we use http */ + protocol: z.enum(["http", "https"]).default("http"), + /** You can provide a token which can improve reliability */ + token: z.string().optional(), +}); + +// POST - /api/auth/login + +export const AuthLoginSchemaSuccess = ResponseBody.extend({ + code: CODE_SUCCESS, + data: z.object({ + token: z.string(), + }), +}); + +export const AuthLoginSchemaInvalidPassword = ResponseBody.extend({ + code: z.literal(-2), +}); + +export const AuthLoginSchema = z.discriminatedUnion("code", [ + AuthLoginSchemaSuccess, + AuthLoginSchemaInvalidPassword, +]); + +// GET - /api/vm/info + +export const InfoSchema = z.object({ + ips: z.array( + z.object({ + name: z.string(), + addr: z.string(), + version: z.string(), + type: z.string(), + }), + ), + mdns: z.string(), + image: z.string(), + application: z.string(), + deviceKey: z.string(), +}); + +export const InfoSuccess = ResponseBody.extend({ + code: CODE_SUCCESS, + data: InfoSchema, +}); + +// GET - /api/vm/gpio + +export const GpioSchema = z.object({ + pwr: z.boolean(), + hdd: z.boolean(), +}); + +export const GpioSuccess = ResponseBody.extend({ + code: CODE_SUCCESS, + data: GpioSchema, +}); + +// POST - /api/vm/gpio + +export const TriggerPowerError = ResponseBody.extend({ + code: z.literal(-1), + data: z.null(), +}); + +export const TriggerPowerSuccess = ResponseBody.extend({ + code: CODE_SUCCESS, + data: z.null(), +}); + +export const TriggerPowerSchema = z.discriminatedUnion("code", [ + TriggerPowerSuccess, + TriggerPowerError, +]); + +export const TriggerPowerInput = z.object({ + type: z.literal("power").default("power"), + duration: z.number().min(0).max(20000).default(800), +}); diff --git a/src/clients/nano-kvm/nano-kvm-client.ts b/src/clients/nano-kvm/nano-kvm-client.ts new file mode 100644 index 0000000..7bab8c0 --- /dev/null +++ b/src/clients/nano-kvm/nano-kvm-client.ts @@ -0,0 +1,90 @@ +import type { BodyInit } from "bun"; +import type z from "zod"; +import { encryptPassword } from "@/encryption"; +import { logger } from "@/logger"; +import { + type AuthLoginSchema, + NanoKVMClientOptions, +} from "./nano-kvm-client.schema"; + +export class NanoKVMClient { + private baseUrl: URL; + private options: z.output; + private token: string | undefined; + + constructor(options: z.input) { + this.options = NanoKVMClientOptions.parse(options); + + this.baseUrl = new URL(`${this.options.protocol}://${this.options.host}`); + } + + get authUrl() { + return new URL("/api/auth/login", this.baseUrl); + } + + async init() { + if (this.options.token) { + this.token = this.options.token; + + logger.info("sucessfully initialized nanokvm using token from config"); + return; + } + + this.loginAndSetToken(); + } + + async fetch(path: string, method?: "POST" | "GET", body?: BodyInit) { + return fetch(new URL(path, this.baseUrl), { + headers: { + cookie: `nano-kvm-token=${this.token}`, + }, + body, + method, + }); + } + + /** Calls the login api and stores the returned token */ + async loginAndSetToken() { + const response = await fetch(this.authUrl, { + headers: { + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + username: this.options.username, + password: encryptPassword( + this.options.password, + this.options.passwordSecret, + ), + }), + }); + + const result = (await response.json()) as z.output< + typeof AuthLoginSchema + > | null; + + if (result === null) { + throw new Error("No response from api!"); + } + + switch (result.code) { + case 0: { + logger.debug(`Got token ${result.data.token}`); + + this.token = result.data.token; + return; + } + case -2: { + logger.fatal(`Invalid password! Failed to get token.`); + + return; + } + default: { + logger.fatal( + "Unknown error while getting token.", + (result as z.output).msg, + ); + } + } + } +} diff --git a/src/clients/nano-kvm/nano-kvm.service.ts b/src/clients/nano-kvm/nano-kvm.service.ts new file mode 100644 index 0000000..a0605bc --- /dev/null +++ b/src/clients/nano-kvm/nano-kvm.service.ts @@ -0,0 +1,64 @@ +import type z from "zod"; +import { NanoKVMClient } from "./nano-kvm-client"; +import { + type GpioSchema, + GpioSuccess, + type InfoSchema, + InfoSuccess, + type NanoKVMClientOptions, + TriggerPowerInput, + TriggerPowerSchema, +} from "./nano-kvm-client.schema"; + +export class NanoKVMService { + private client: NanoKVMClient; + + constructor(options: { + clientOptions: z.input; + }) { + this.client = new NanoKVMClient(options.clientOptions); + } + + async init() { + await this.client.init(); + } + + async getInfo(): Promise> { + const data = await (await this.client.fetch("/api/vm/info", "GET")).json(); + + return InfoSuccess.parse(data).data; + } + + async getGpio(): Promise> { + const response = await this.client.fetch("/api/vm/gpio", "GET"); + + const data = await response.json(); + + return GpioSuccess.parse(data).data; + } + + async triggerPower(input: z.input) { + const parsedInput = TriggerPowerInput.parse(input); + + console.log(parsedInput); + + const body = new FormData(); + + body.append("Type", parsedInput.type); + body.append("Duration", parsedInput.duration.toString()); + + const response = await this.client.fetch("/api/vm/gpio", "POST", body); + + const data = await response.json(); + + const parsed = TriggerPowerSchema.parse(data); + + return parsed; + } + + async triggerReset() { + const result = await this.triggerPower({ duration: 8000 }); + + return result; + } +} diff --git a/src/config.schema.ts b/src/config.schema.ts new file mode 100644 index 0000000..2ac798f --- /dev/null +++ b/src/config.schema.ts @@ -0,0 +1,9 @@ +import z from "zod"; +import { MQTTClientOptions } from "./clients/mqtt/mqtt-client.schema"; +import { NanoKVMClientOptions } from "./clients/nano-kvm/nano-kvm-client.schema"; + +export const ConfigSchema = z.object({ + nanoKvm: NanoKVMClientOptions, + mqtt: MQTTClientOptions, + publishInterval: z.number().default(2000), +}); diff --git a/src/encryption.ts b/src/encryption.ts new file mode 100644 index 0000000..07f888b --- /dev/null +++ b/src/encryption.ts @@ -0,0 +1,47 @@ +import crypto from "node:crypto"; + +export const encryptPassword = (password: string, passphrase: string) => { + // OpenSSL's default salt is 8 bytes + const salt = crypto.randomBytes(8); + + // Derive key and IV using MD5 (matching OpenSSL's -md md5) + // OpenSSL uses EVP_BytesToKey algorithm + const keyIv = evpBytesToKey(passphrase, salt, 32, 16); // 32 bytes key, 16 bytes IV for AES-256 + + // Create cipher + const cipher = crypto.createCipheriv("aes-256-cbc", keyIv.key, keyIv.iv); + + // Encrypt the password + let encrypted = cipher.update(password, "utf8"); + encrypted = Buffer.concat([encrypted, cipher.final()]); + + // OpenSSL prepends "Salted__" + salt to the encrypted data + const saltedPrefix = Buffer.from("Salted__", "utf8"); + const result = Buffer.concat([saltedPrefix, salt, encrypted]); + + // Return base64 encoded result + return result.toString("base64"); +}; + +// EVP_BytesToKey implementation (OpenSSL's key derivation) +const evpBytesToKey = ( + password: string, + salt: Buffer, + keyLen: number, + ivLen: number, +) => { + const md5 = (data: Buffer) => crypto.createHash("md5").update(data).digest(); + + let derived = Buffer.alloc(0); + let hash = Buffer.alloc(0); + + while (derived.length < keyLen + ivLen) { + hash = md5(Buffer.concat([hash, Buffer.from(password, "utf8"), salt])); + derived = Buffer.concat([derived, hash]); + } + + return { + key: derived.slice(0, keyLen), + iv: derived.slice(keyLen, keyLen + ivLen), + }; +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1ada8b2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,68 @@ +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { $, Glob } from "bun"; +import { getMqttClient } from "./clients/mqtt/mqtt-client"; +import { NanoKVMService } from "./clients/nano-kvm/nano-kvm.service"; +import { ConfigSchema } from "./config.schema"; +import { startListeners } from "./listener"; +import { logger } from "./logger"; +import { runPublishLoop } from "./publisher"; + +logger.info("Starting intialization..."); + +let hasMultipleConfigs = false; + +const validConfigs = []; + +try { + const glob = new Glob("*.client.json"); + + const configsFolder = process.env.CONFIGS_PATH || "./"; + + for await (const fileName of glob.scan(configsFolder)) { + logger.info(`Found client config ${fileName}, validating...`); + + const fileContent = await readFile(join(configsFolder, fileName), { + encoding: "utf8", + }).catch((e) => { + throw new Error("Error while reading config!", { cause: e }); + }); + + const json = JSON.parse(fileContent); + + const result = ConfigSchema.safeParse(json); + + if (result.success) { + validConfigs.push(result.data); + } + + if (result.error) { + logger.fatal(result.error); + } + } + + if (validConfigs.length > 0) { + hasMultipleConfigs = true; + } + + throw new Error("No valid config found! Exiting..."); +} catch {} + +logger.info( + hasMultipleConfigs + ? `Successfully loaded ${validConfigs.length} configs!` + : "Single config mode", +); + +for (const config of validConfigs) { + const nanoKvmService = new NanoKVMService({ clientOptions: config.nanoKvm }); + + await nanoKvmService.init(); + + const info = await nanoKvmService.getInfo(); + + const mqttClient = await getMqttClient(config.mqtt); + + runPublishLoop(info, nanoKvmService, config, mqttClient); + startListeners(info, nanoKvmService, config, mqttClient); +} diff --git a/src/listener.ts b/src/listener.ts new file mode 100644 index 0000000..b1fec47 --- /dev/null +++ b/src/listener.ts @@ -0,0 +1,40 @@ +import type { MqttClient } from "mqtt"; +import type z from "zod"; +import type { NanoKVMService } from "./clients/nano-kvm/nano-kvm.service"; +import type { InfoSchema } from "./clients/nano-kvm/nano-kvm-client.schema"; +import type { ConfigSchema } from "./config.schema"; +import { logger } from "./logger"; + +export async function startListeners( + info: z.output, + nanoKvmService: NanoKVMService, + _config: z.output, + mqttClient: MqttClient, +) { + await mqttClient.subscribeAsync(`nanokvm-mqtt/${info.deviceKey}/+`); + + mqttClient.on("message", async (topic, payload) => { + logger.info(topic, payload); + + const pathParts = topic.split("/"); + + const action = pathParts[pathParts.length - 1]; + + switch (action) { + case "trigger-power": { + await nanoKvmService.triggerPower({}); + + return; + } + case "trigger-reset": { + await nanoKvmService.triggerReset(); + + return; + } + + default: { + throw new Error(`Unhandled action: ${action}`); + } + } + }); +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..bf7d224 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,5 @@ +import { Logger } from "tslog"; + +export const logger = new Logger({ + name: "nanokvm-mqtt", +}); diff --git a/src/publisher.ts b/src/publisher.ts new file mode 100644 index 0000000..81d29ad --- /dev/null +++ b/src/publisher.ts @@ -0,0 +1,128 @@ +import { sleep } from "bun"; +import type { MqttClient } from "mqtt"; +import type z from "zod"; +import type { NanoKVMService } from "./clients/nano-kvm/nano-kvm.service"; +import type { InfoSchema } from "./clients/nano-kvm/nano-kvm-client.schema"; +import type { ConfigSchema } from "./config.schema"; +import { logger } from "./logger"; + +export async function runPublishLoop( + info: z.output, + nanoKvmService: NanoKVMService, + config: z.output, + mqttClient: MqttClient, +) { + while (true) { + const entityIds = { + gpio: { + hdd: "gpio_hdd", + power: "gpio_power", + }, + actions: { + triggerPower: "action_trigger_power", + triggerReset: "action_trigger_reset", + }, + }; + + const origin = { + name: "nanokvm-mqtt", + sw: "0.0.1", + url: "https://github.com/enricobuehler/nanokvm-mqtt", + }; + + const state_topic = `nanokvm-mqtt/${info.deviceKey}`; + + const device = { + hw_version: 0, + identifiers: [info.deviceKey], + manufacturer: "Sipeed", + model: "Sipeed NanoKVM", + model_id: "unknown", + name: info.mdns, + sw_version: info.application, + }; + + // State + + await mqttClient.publishAsync( + `homeassistant/binary_sensor/${info.deviceKey}/power/config`, + JSON.stringify({ + default_entity_id: `binary_sensor.${info.deviceKey}_${entityIds.gpio.power}`, + device, + device_class: "power", + object_id: `nanokvm_${info.deviceKey}`, + origin, + state_topic, + payload_off: false, + payload_on: true, + unique_id: `${info.deviceKey}_nanokvm_${entityIds.gpio.power}`, + value_template: "{{ value_json.power }}", + }), + ); + + await mqttClient.publishAsync( + `homeassistant/binary_sensor/${info.deviceKey}/hdd/config`, + JSON.stringify({ + default_entity_id: `binary_sensor.${info.deviceKey}_${entityIds.gpio.hdd}`, + device, + name: "HDD Activity", + object_id: `nanokvm_${info.deviceKey}_${entityIds.gpio.hdd}`, + origin, + state_topic, + payload_off: false, + payload_on: true, + unique_id: `${info.deviceKey}_nanokvm_${entityIds.gpio.hdd}`, + value_template: "{{ value_json.hdd }}", + }), + ); + + logger.debug("AutoDiscovery - Published states"); + + // Actions + + await mqttClient.publishAsync( + `homeassistant/button/${info.deviceKey}/button-trigger-power/config`, + JSON.stringify({ + device, + command_topic: `${state_topic}/trigger-power`, + default_entity_id: `button.${info.deviceKey}_${entityIds.actions.triggerPower}`, + name: "Trigger Power", + object_id: `nanokvm_${info.deviceKey}_${entityIds.actions.triggerPower}`, + payload_press: "trigger_power", + unique_id: `${info.deviceKey}_nanokvm_${entityIds.actions.triggerPower}`, + }), + ); + + await mqttClient.publishAsync( + `homeassistant/button/${info.deviceKey}/button-trigger-reset/config`, + JSON.stringify({ + device, + command_topic: `${state_topic}/trigger-reset`, + default_entity_id: `button.${info.deviceKey}_${entityIds.actions.triggerReset}`, + name: "Trigger Reset", + object_id: `nanokvm_${info.deviceKey}_${entityIds.actions.triggerReset}`, + payload_press: "trigger_reset", + unique_id: `${info.deviceKey}_nanokvm_${entityIds.actions.triggerReset}`, + }), + ); + + // + + logger.debug("AutoDiscovery - Published actions"); + + const gpio = await nanoKvmService.getGpio(); + + await mqttClient.publishAsync( + `nanokvm-mqtt/${info.deviceKey}`, + JSON.stringify({ + power: gpio.pwr, + hdd: gpio.hdd, + }), + ); + + //console.log(info.ips); + //console.log(gpio); + + await sleep(config.publishInterval); + } +} diff --git a/src/scripts/generate-config-json-schema.ts b/src/scripts/generate-config-json-schema.ts new file mode 100644 index 0000000..47febd6 --- /dev/null +++ b/src/scripts/generate-config-json-schema.ts @@ -0,0 +1,7 @@ +import { writeFile } from "node:fs/promises"; +import z from "zod"; +import { ConfigSchema } from "@/config.schema"; + +const jsonSchema = z.toJSONSchema(ConfigSchema); + +await writeFile("./config-schema.json", JSON.stringify(jsonSchema)); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6c33044 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..b3660ca --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: "src/index.ts", + external: ["bun"], +});