commit 32a690d85b90dd605610d0824b61ed95dd7f7744 Author: ztimson Date: Sun Apr 5 20:27:43 2026 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a1537b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +node_modules diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..ccf4aad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Zakary Timson + +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. diff --git a/README.md b/README.md new file mode 100755 index 0000000..17e4f51 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Multipurpose Radio Server (MRS) + +--- + +###### By: Zakary Timson +###### Date: September 16, 2025 +###### Version: 1.0 + +--- + +![](https://meshtastic.org/assets/images/lora-topology-2-c80684f1eafdf2a71fbaf26e494fb26d.webp) + +> Compact communication and information exchange server on Raspberry Pi for service-denied areas. +> Ideal for disasters, unserviced environments, long-range tracking, and offline-first operations. + +--- + +## Table of Contents +- [Multipurpose Radio Server](#multipurpose-radio-server-mrs) + - [Description](#description) + - [Inspiration](#inspiration) + - [Capabilities](#capabilities) + - [Manual](#manual) + - [Setup](docs/setup.md) + - [Introduction](#introduction) + - [Connecting & Wifi](#connecting) + - [Mesh Network](#mesh-network) + - [LoRa Radio](#lora-radio) + - [FileBrowser](#filebrowser) + - [Desktop Environment](#desktop-environment) + - Services + - [AI Assistant](#ai-assistant) + - [Archive](#archive) + - [ATAK](#atak) + - [Maps](docs/maps.md) + - [Messaging](#messaging) + - [Pentesting](#pentesting) + - [SDR](#sdr) + - [SSH](#ssh) + - [VPN](#vpn) + - [Todo](#todo) + - [License](LICENSE) + +--- + +## Description + +### Inspiration +- [A.C.I.D](https://www.youtube.com/watch?v=nSrYmkGq9TM) +- [Offline internet](https://www.youtube.com/watch?v=L5RJZmuRJKA) +- [Meshtastic radios](https://morosx.com/ols/products/xtak-lora-mesh) +- [HaLow mesh network](https://www.youtube.com/watch?v=ofR7GFNZzJY) +- [Signal Intelligence](https://www.youtube.com/watch?v=Nam87B2u6bo&t=1942s) + +### Capabilities + +**Networking** +- 900 MHz **HaLow** / 2.4 GHz / 5 GHz Mesh WiFi using **B.A.T.M.A.N.** (1 km +) +- LoRa radio communication (1-100s of kms) +- Connect to AP with QR code using **dnsmasq, hostapd** +- Captive portal & device home page + settings page + +**Communication** +- Offline & federated text/voice/file chat using **Synapse** and **Element** +- Built-in LoRa **Meshtastic** node (≈2–15 km) +- GPS positioning & offline maps using **gpsd, chrony** +- Track people & logistics with **ATAK** + +**Information Archive** +- Offline AI/LLM assistant using **Ollama** and **OpenWebUI** +- Offline Wikipedia, Ghetesburg project & more using **Kiwix** +- File sharing & syncing using **FileBrowser** and **Rclone** +- Remote desktop usig **RDP** and **Apache Guacamole** + +**Recon & Security** +- WiFi & Bluetooth reconnaissance with **Bettercap, nmap, masscan** and more +- Red team operations with **aircrack, nikto, Metasploit** and more +- Radio frequency recon using **SDRAngel** +- ADS-B plane tracking with **tar1090** + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/code/package-lock.json b/code/package-lock.json new file mode 100755 index 0000000..0e95487 --- /dev/null +++ b/code/package-lock.json @@ -0,0 +1,370 @@ +{ + "name": "dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "node-gpsd": "^0.3.4" + }, + "devDependencies": { + "nodemon": "^3.1.10" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/node-gpsd": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/node-gpsd/-/node-gpsd-0.3.4.tgz", + "integrity": "sha512-sI9hPfHiaWDmhjE1oJZnhMo7UF2vQVGl3qk0K4HbN1L8BkS0I+rd6V687eHOj/6ervXiHrhwXzYYsWdXxGy0Qg==", + "engines": { + "node": ">=v0.8.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + } + } +} diff --git a/code/package.json b/code/package.json new file mode 100755 index 0000000..6b47216 --- /dev/null +++ b/code/package.json @@ -0,0 +1,19 @@ +{ + "name": "dashboard", + "version": "1.0.0", + "main": "main.js", + "scripts": { + "start": "node src/main.mjs", + "start:prod": "nodemon src/main.mjs" + }, + "keywords": [], + "author": "", + "license": "MIT", + "description": "Dashboard", + "dependencies": { + "node-gpsd": "^0.3.4" + }, + "devDependencies": { + "nodemon": "^3.1.10" + } +} diff --git a/code/public/downloads/ATAK_5.4.0.16.apk b/code/public/downloads/ATAK_5.4.0.16.apk new file mode 100755 index 0000000..a5f1ca0 Binary files /dev/null and b/code/public/downloads/ATAK_5.4.0.16.apk differ diff --git a/code/public/downloads/Element.apk b/code/public/downloads/Element.apk new file mode 100755 index 0000000..dc372a2 Binary files /dev/null and b/code/public/downloads/Element.apk differ diff --git a/code/public/downloads/Meshtastic_2.7.1.apk b/code/public/downloads/Meshtastic_2.7.1.apk new file mode 100755 index 0000000..7d8ff42 Binary files /dev/null and b/code/public/downloads/Meshtastic_2.7.1.apk differ diff --git a/code/public/downloads/Remote Desktop 8_8.1.82.445.apk b/code/public/downloads/Remote Desktop 8_8.1.82.445.apk new file mode 100755 index 0000000..a9bb0bf Binary files /dev/null and b/code/public/downloads/Remote Desktop 8_8.1.82.445.apk differ diff --git a/code/public/downloads/SDRangel_7.21.3.1.apk b/code/public/downloads/SDRangel_7.21.3.1.apk new file mode 100755 index 0000000..8347b0e Binary files /dev/null and b/code/public/downloads/SDRangel_7.21.3.1.apk differ diff --git a/code/public/favicon.png b/code/public/favicon.png new file mode 100755 index 0000000..4e81999 Binary files /dev/null and b/code/public/favicon.png differ diff --git a/code/public/index.html b/code/public/index.html new file mode 100755 index 0000000..31ee457 --- /dev/null +++ b/code/public/index.html @@ -0,0 +1,953 @@ + + + + MRS Dashboard + + + + + + + +
+
+
+ Logo +
+ +
Hostname
+
+
+
+
+
+
Local
+
00:00:00
+
Jan 1, 2025
+
+
+
Zulu / UTC
+
00:00:00
+
Jan 1, 2025
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + +
+
+ + + + + + +
+
+ + + + +
+
+ + + +
+
+
+
+ +
+
+
+
+ + + + + + + + +
System ?°C
+
+
+
+
CPU - Load
+
+
+
0%
+
+
+
+
Memory
+
+
+
0%
+
+
+
+
Disk
+
+
+
0%
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + +
GPS
+
+
+
39.2000, -49.0000
+
No Fix
+
+
+ +
+
+ + + + + +
LoRa
+
+
+
0 / 0 Online
+
Ch 0% Air 0%
+
+
+ +
+
+ + + + + + + + + + + + + + + + +
Mesh
+
+
+
Connected
+
900 Mhz -45 dBm
+
+
+ +
+
+ + + + + + +
WiFi
+
+
+
Access Point
+
SSID: Hostname
+
+
+ +
+
+ + + + +
Ethernet
+
+
+
Disconnected
+
No cable detected
+
+
+ +
+
+
+ +
+ + + +
+ + + + diff --git a/code/public/index.js b/code/public/index.js new file mode 100755 index 0000000..d07c59d --- /dev/null +++ b/code/public/index.js @@ -0,0 +1,368 @@ + +class Dashboard { + constructor() { + this.statusCache = null; + this.lastFetch = 0; + this.hostname = 'localhost'; + this.serverTimeOffset = null; // Track difference between server and client time + this.isTimeServerSynced = false; // Track if we're using server time + this.init(); + } + + async init() { + await this.fetchHostname(); + this.updateHostname(); + this.startTimeUpdates(); // Start with client time immediately + this.startStatusUpdates(); + this.initializeEventListeners(); + } + + async fetchHostname() { + try { + const response = await fetch('/api/hostname'); + const data = await response.json(); + this.hostname = data.hostname.toLowerCase(); + } catch (error) { + console.warn('Failed to fetch hostname:', error); + } + } + + updateHostname() { + const hostnameElement = document.querySelector('.hostname'); + if (hostnameElement) { + hostnameElement.textContent = this.hostname; + } + } + + async fetchStatus() { + try { + // Record start time for latency calculation + const requestStart = performance.now(); + const response = await fetch('/api/status'); + const requestEnd = performance.now(); + + if (!response.ok) throw new Error('Status fetch failed'); + + const status = await response.json(); + this.statusCache = status; + this.updateUI(status, requestStart, requestEnd); + } catch (error) { + console.error('Failed to fetch status:', error); + this.showOfflineStatus(); + } + } + + updateUI(status, requestStart, requestEnd) { + this.updateSystemMetrics(status.system); + this.updateGPSStatus(status.gps); + this.updateNetworkStatus(status.network); + this.updateLoRaStatus(status.lora); + this.updateTimeSync(status.time, requestStart, requestEnd); + } + + updateSystemMetrics(system) { + // Update temperature + const tempElement = document.getElementById('temp'); + if (tempElement && system.temperature) { + tempElement.textContent = `${system.temperature}°C`; + } + + // Update load average + const loadElement = document.getElementById('load'); + if (loadElement && system.load !== undefined) { + loadElement.textContent = `${system.load.toFixed(1)} Load`; + } + + // Update progress bars + this.updateProgressBar('cpu-usage', system.cpu); + this.updateProgressBar('memory-usage', system.memory); + this.updateProgressBar('disk-usage', system.storage); + } + + updateProgressBar(id, percentage) { + const fillElement = document.getElementById(`${id}-fill`); + const textElement = document.getElementById(`${id}-text`); + + if (fillElement && textElement) { + const value = Math.round(percentage); + fillElement.style.width = `${value}%`; + textElement.textContent = `${value}%`; + + // Update color based on usage + if (value > 80) { + fillElement.style.background = '#ef4444'; // Red + } else if (value > 60) { + fillElement.style.background = '#f59e0b'; // Orange + } else { + fillElement.style.background = '#10b981'; // Green + } + } + } + + updateGPSStatus(gps) { + const gpsStatus = document.getElementById('gps-status'); + const gpsTile = document.getElementById('gps-tile'); + + if (gpsStatus && gpsTile) { + const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null; + + // Update status indicator + if (hasValidFix) { + gpsStatus.classList.remove('disconnected'); + gpsStatus.classList.add('connected'); + gpsTile.classList.remove('disconnected'); + gpsTile.classList.add('connected'); + } else { + gpsStatus.classList.remove('connected'); + gpsStatus.classList.add('disconnected'); + gpsTile.classList.remove('connected'); + gpsTile.classList.add('disconnected'); + } + + // Update GPS tile info + const gpsInfo = gpsTile.querySelector('.tile-info'); + const gpsDetail = gpsTile.querySelector('.tile-detail'); + + if (gpsInfo && gpsDetail) { + if (hasValidFix) { + gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`; + gpsDetail.textContent = `${gps.fix} • ${gps.satellites} sats • Alt: ${Math.round(gps.altitude)}m`; + } else { + gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Signal'; + gpsDetail.textContent = `${gps.satellites} satellites`; + } + } + } + } + + updateNetworkStatus(network) { + // Update WiFi status + const wifiTile = document.getElementById('wifi-tile'); + if (wifiTile) { + const wifiInfo = wifiTile.querySelector('.tile-info'); + const wifiDetail = wifiTile.querySelector('.tile-detail'); + + if (wifiInfo && wifiDetail) { + if (network.wifi.mode === 'ap') { + wifiInfo.textContent = 'Access Point'; + wifiDetail.textContent = `SSID: ${network.wifi.ssid}`; + } else { + wifiInfo.textContent = 'Client Mode'; + wifiDetail.textContent = `SSID: ${network.wifi.ssid}`; + } + } + } + + // Update Ethernet status + const ethernetStatus = document.getElementById('ethernet-status'); + const ethernetTile = document.getElementById('ethernet-tile'); + + if (ethernetStatus && ethernetTile) { + if (network.ethernet.connected) { + ethernetStatus.classList.remove('disconnected'); + ethernetStatus.classList.add('connected'); + ethernetTile.classList.remove('disconnected'); + ethernetTile.classList.add('connected'); + + const ethernetInfo = ethernetTile.querySelector('.tile-info'); + const ethernetDetail = ethernetTile.querySelector('.tile-detail'); + if (ethernetInfo && ethernetDetail) { + ethernetInfo.textContent = 'Connected'; + ethernetDetail.textContent = 'Link detected'; + } + } else { + ethernetStatus.classList.remove('connected'); + ethernetStatus.classList.add('disconnected'); + ethernetTile.classList.remove('connected'); + ethernetTile.classList.add('disconnected'); + + const ethernetInfo = ethernetTile.querySelector('.tile-info'); + const ethernetDetail = ethernetTile.querySelector('.tile-detail'); + if (ethernetInfo && ethernetDetail) { + ethernetInfo.textContent = 'Disconnected'; + ethernetDetail.textContent = 'No cable detected'; + } + } + } + } + + updateLoRaStatus(lora) { + const loraStatus = document.getElementById('lora-status'); + const loraTile = document.getElementById('lora-tile'); + + if (loraStatus && loraTile) { + if (lora.connected) { + loraStatus.classList.remove('disconnected'); + loraStatus.classList.add('connected'); + loraTile.classList.remove('disconnected'); + loraTile.classList.add('connected'); + + const loraInfo = loraTile.querySelector('.tile-info'); + const loraDetail = loraTile.querySelector('.tile-detail'); + + if (loraInfo && loraDetail) { + loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`; + loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`; + } + } else { + loraStatus.classList.remove('connected'); + loraStatus.classList.add('disconnected'); + loraTile.classList.remove('connected'); + loraTile.classList.add('disconnected'); + + const loraInfo = loraTile.querySelector('.tile-info'); + const loraDetail = loraTile.querySelector('.tile-detail'); + + if (loraInfo && loraDetail) { + loraInfo.textContent = 'Disconnected'; + loraDetail.textContent = 'No device found'; + } + } + } + } + + updateTimeSync(serverTimeString, requestStart, requestEnd) { + if (serverTimeString && requestStart && requestEnd) { + // Calculate network latency (round-trip time / 2) + const networkLatency = (requestEnd - requestStart) / 2; + + // Parse server time and adjust for network latency + const serverTime = new Date(serverTimeString); + const adjustedServerTime = new Date(serverTime.getTime() + networkLatency); + + // Calculate the offset between adjusted server time and client time + const clientTime = new Date(); + this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime(); + this.isTimeServerSynced = true; + + console.log(`Time synchronized with server (latency: ${networkLatency.toFixed(1)}ms, offset: ${this.serverTimeOffset.toFixed(1)}ms)`); + } + + // Update time displays + this.updateCurrentTime(); + } + + updateCurrentTime() { + const now = new Date(); + let displayTime; + + if (this.isTimeServerSynced && this.serverTimeOffset !== null) { + // Use server-synchronized time + displayTime = new Date(now.getTime() + this.serverTimeOffset); + } else { + // Use client time (default) + displayTime = now; + } + + this.updateTimeDisplay('local', displayTime, false); + this.updateTimeDisplay('zulu', displayTime, true); + } + + updateTimeDisplay(type, date, isUtc) { + const timeElement = document.getElementById(`${type}-time`); + const dateElement = document.getElementById(`${type}-date`); + + if (timeElement && dateElement) { + const displayDate = isUtc ? new Date(date.getTime()) : date; + + const timeStr = isUtc ? + displayDate.toUTCString().split(' ')[4] : + displayDate.toLocaleTimeString(); + + const dateStr = isUtc ? + displayDate.toUTCString().split(' ').slice(0, 4).join(' ') : + displayDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + + timeElement.textContent = timeStr; + dateElement.textContent = dateStr; + + // Color time orange when using client time, white when server-synced + if (this.isTimeServerSynced) { + timeElement.style.color = '#fff'; + dateElement.style.color = '#64748b'; + } else { + timeElement.style.color = '#f59e0b'; + dateElement.style.color = '#f59e0b'; + } + } + } + + showOfflineStatus() { + // Reset to client time and show orange color + this.serverTimeOffset = null; + this.isTimeServerSynced = false; + console.warn('System appears offline - falling back to client time'); + } + + startStatusUpdates() { + // Initial fetch + this.fetchStatus(); + + // Update every 60 seconds + setInterval(() => { + this.fetchStatus(); + }, 60000); + } + + startTimeUpdates() { + // Start immediately with client time + this.updateCurrentTime(); + + // Update time every second + setInterval(() => { + this.updateCurrentTime(); + }, 1000); + } + + initializeEventListeners() { + // Expand/collapse status + const expandToggle = document.getElementById('expand-toggle'); + const expandedStatus = document.getElementById('expanded-status'); + const expandIcon = document.getElementById('expand-icon'); + + if (expandToggle && expandedStatus && expandIcon) { + expandToggle.addEventListener('click', () => { + expandedStatus.classList.toggle('visible'); + expandIcon.classList.toggle('expanded'); + }); + } + + // Search functionality + const searchInput = document.getElementById('search-input'); + const appsGrid = document.getElementById('apps-grid'); + + if (searchInput && appsGrid) { + searchInput.addEventListener('input', (e) => { + const query = e.target.value.toLowerCase(); + const apps = appsGrid.querySelectorAll('.app-item'); + + apps.forEach(app => { + const name = app.getAttribute('data-name')?.toLowerCase() || ''; + app.style.display = name.includes(query) ? 'flex' : 'none'; + }); + }); + } + } +} + +function openKiwixm(event) { + event.stopPropagation(); + event.preventDefault(); + window.location.href = window.location.origin.replace(/:\d+/g, '') + ':1300/admin'; +} + +// Initialize dashboard when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + new Dashboard(); + + document.querySelectorAll('.app-item[href^=":"]').forEach(link => { + const port = link.getAttribute('href').substring(1); // Remove the ':' + const url = new URL(location.origin); + url.port = port; + link.href = url.toString(); + }); +}); diff --git a/code/public/manifest.json b/code/public/manifest.json new file mode 100755 index 0000000..7b9f337 --- /dev/null +++ b/code/public/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "MRS", + "short_name": "MRS", + "description": "Multipurpose Radio Server", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "lang": "en-US", + "scope": "/" +} diff --git a/code/src/config.json b/code/src/config.json new file mode 100644 index 0000000..e69de29 diff --git a/code/src/main.mjs b/code/src/main.mjs new file mode 100755 index 0000000..ebecdb9 --- /dev/null +++ b/code/src/main.mjs @@ -0,0 +1,51 @@ + +import express from "express"; +import { join } from 'path'; +import {environment} from './services/environment.mjs'; +import { statusRouter } from './services/status.mjs'; + +(async () => { + const app = express(); + + // Middleware + app.use(express.json()); + app.use((err, req, res, next) => { + console.error('Middleware error:', err); + res.status(500).json({ error: 'Internal server error' }); + }); + + // Routes + console.log(join(environment.root, '../public')); + app.use(express.static(join(environment.root, '../public'))); + + app.use('/api', statusRouter); + + // Error handler + app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); + }); + + // Startup + const server = app.listen(environment.port, () => { + console.log(`Dashboard running on http://localhost:${environment.port}`); + }).on('error', (err) => { + console.error('Server startup error:', err); + process.exit(1); + }); + + // Shutdown + const gracefulShutdown = (signal) => { + console.log(`Received ${signal}, shutting down gracefully`); + server.close((err) => { + if (err) { + console.error('Error during server shutdown:', err); + process.exit(1); + } + console.log('Server closed'); + process.exit(0); + }); + }; + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); +})(); diff --git a/code/src/services/environment.mjs b/code/src/services/environment.mjs new file mode 100755 index 0000000..ca0ef1d --- /dev/null +++ b/code/src/services/environment.mjs @@ -0,0 +1,38 @@ +import dotenv from 'dotenv'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { execSync } from "child_process"; + +dotenv.config(); + +export const environment = { + config: process.env.CONFIG || 'config.json', + hostname: process.env.HOSTNAME || execSync('hostname', { encoding: "utf-8" }) || 'localhost', + gpsd: process.env.GPSD || 2947, + port: process.env.PORT || 80, + root: dirname(dirname(fileURLToPath(import.meta.url))), + settings: {}, +} + +if(!environment.config.startsWith('/')) environment.config = `${environment.root}/${environment.config}`; + +export function loadSettings() { + if(!fs.existsSync(environment.config)) fs.writeFileSync(environment.config, '', { flag: 'a' }); + const value = fs.readFileSync(environment.config, 'utf-8'); + try { return JSON.parse(value); } + catch (e) { + return { + lora: { + mode: 'MESH', + telemetry: true, + } + }; + } +} + +export function saveSettings() { + fs.writeFileSync(environment.config, JSON.stringify(environment.settings, null, 4)); +} + +loadSettings(); diff --git a/code/src/services/status.mjs b/code/src/services/status.mjs new file mode 100755 index 0000000..7d6f7ee --- /dev/null +++ b/code/src/services/status.mjs @@ -0,0 +1,426 @@ + +import express from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { readFileSync, existsSync } from 'fs'; +import pkg from 'node-gpsd'; +const { GPS } = pkg; +import {environment} from './environment.mjs'; + +const router = express.Router(); +const execAsync = promisify(exec); + +// Status cache with TTL +let statusCache = null; +let lastUpdate = 0; +const CACHE_TTL = 15 * 1000; // 15 seconds + +// GPS client setup +let gpsClient = null; +let gpsData = { + fix: 'No Fix', + latitude: null, + longitude: null, + altitude: null, + satellites: 0 +}; + +// Initialize GPS connection +async function initGPS() { + try { + gpsClient = new GPS({ hostname: 'localhost', port: 2947 }); + + gpsClient.on('TPV', (data) => { + gpsData.latitude = data.lat || null; + gpsData.longitude = data.lon || null; + gpsData.altitude = data.alt || null; + gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix'; + }); + + gpsClient.on('SKY', (data) => { + gpsData.satellites = (data.satellites || []).length; + }); + + gpsClient.on('error', (err) => { + console.warn('GPS error:', err.message); + }); + + await gpsClient.watch(); + console.log('GPS client initialized'); + } catch (error) { + console.warn('GPS not available:', error.message); + } +} + +// CLI-based system information gathering +async function getSystemInfo() { + const info = { + temperature: 0, + load: 0, + cpu: 0, + memory: 0, + storage: 0 + }; + + try { + // CPU temperature (Raspberry Pi specific) + if (existsSync('/sys/class/thermal/thermal_zone0/temp')) { + const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8'); + info.temperature = Math.round(parseInt(tempData.trim()) / 1000); + } + + // Load average + const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'"); + info.load = parseFloat(loadAvg.trim()) || 0; + + // CPU usage + const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'"); + info.cpu = parseFloat(cpuUsage.trim()) || 0; + + // Memory usage percentage + const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'"); + info.memory = parseFloat(memUsage.trim()) || 0; + + // Disk usage percentage for root filesystem + const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'"); + info.storage = parseFloat(diskUsage.trim()) || 0; + + } catch (error) { + console.warn('System info error:', error.message); + } + + return info; +} + +async function getGPSInfo() { + const gpsInfo = { + satellites: 0, + latitude: null, + longitude: null, + altitude: null + }; + + try { + // Check if gpsd is running + await execAsync('systemctl is-active gpsd'); + + // Try to get GPS data using gpspipe + const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 }); + + if (gpsRaw.trim()) { + const gpsJson = JSON.parse(gpsRaw.trim()); + gpsInfo.latitude = gpsJson.lat || null; + gpsInfo.longitude = gpsJson.lon || null; + gpsInfo.altitude = gpsJson.alt || null; + } + + // Get satellite count + try { + const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 }); + if (skyRaw.trim()) { + const skyJson = JSON.parse(skyRaw.trim()); + gpsInfo.satellites = (skyJson.satellites || []).length; + } + } catch (skyError) { } + } catch (error) { + console.warn('GPS info error:', error.message); + // Use cached GPS data from node-gpsd if available + if (gpsData.latitude !== null) { + return gpsData; + } + } + + return gpsInfo; +} + +async function getNetworkInfo() { + const networkInfo = { + wifi: { + mode: 'client', + ssid: 'Unknown' + }, + ethernet: { + connected: false + } + }; + + try { + // Check if hostapd is running (AP mode) + try { + await execAsync('systemctl is-active hostapd'); + networkInfo.wifi.mode = 'ap'; + + // Get AP SSID from hostapd config or use hostname + try { + const { stdout: hostname } = await execAsync('hostname'); + networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim(); + } catch { + networkInfo.wifi.ssid = 'MRS-AP'; + } + } catch { + // Not in AP mode, check if connected to WiFi + try { + // First, try iwconfig directly since it's more reliable for ESSID + const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null"); + const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/); + + if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') { + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = essidMatch[1]; + } else { + // Fallback to nmcli if iwconfig doesn't work + try { + const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1"); + if (activeWifi.trim()) { + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = activeWifi.trim(); + } else { + // Try getting SSID from currently connected interface + const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1"); + if (connectedSSID.trim()) { + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = connectedSSID.trim(); + } else { + throw new Error('No active WiFi connection found'); + } + } + } catch { + // Final fallback: check if wlan0 interface is up and get info via ip + const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l"); + if (parseInt(wlanStatus.trim()) > 0) { + // Interface has IP but we can't determine SSID + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = 'Connected (Unknown SSID)'; + } else { + networkInfo.wifi.mode = 'disconnected'; + networkInfo.wifi.ssid = 'Not Connected'; + } + } + } + } catch (error) { + console.warn('WiFi detection error:', error.message); + networkInfo.wifi.mode = 'disconnected'; + networkInfo.wifi.ssid = 'Not Connected'; + } + } + + // Check Ethernet status + try { + const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'"); + networkInfo.ethernet.connected = ethStatus.includes('state UP'); + } catch { + networkInfo.ethernet.connected = false; + } + + } catch (error) { + console.warn('Network info error:', error.message); + } + + return networkInfo; +} + +let loraInfo = null; +async function getLoRaInfo() { + if(loraInfo) return loraInfo; + + loraInfo = { + connected: false, + onlineNodes: 0, + totalNodes: 0, + channelUtilization: 0, + airtimeUtilization: 0 + }; + + try { + console.log('Getting LoRa/Meshtastic status...'); + + // Get node information first + const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 }); + + if (nodeList && nodeList.trim()) { + loraInfo.connected = true; + + // Parse node list - look for actual node entries + const lines = nodeList.split('\n'); + let totalNodes = 0; + let onlineNodes = 0; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip header lines and separators + if (trimmedLine.includes('│') && + !trimmedLine.includes('User') && + !trimmedLine.includes('───') && + !trimmedLine.includes('Node') && + trimmedLine.length > 10) { + + totalNodes++; + + // Check if node is online (has recent lastHeard or SNR data) + if (trimmedLine.includes('SNR') || + trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s + trimmedLine.includes('now')) { + onlineNodes++; + } + } + } + + loraInfo.totalNodes = totalNodes; + loraInfo.onlineNodes = onlineNodes; + } + + // Get channel and airtime utilization from --info + try { + const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 }); + + if (meshInfo && meshInfo.trim()) { + const lines = meshInfo.split('\n'); + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Look for channel utilization (various formats) + if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) { + const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); + if (channelMatch) { + loraInfo.channelUtilization = parseFloat(channelMatch[1]); + } + } + + // Look for airtime utilization (various formats) + if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) { + const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); + if (airtimeMatch) { + loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]); + } + } + } + } + } catch (infoError) { + console.warn('Failed to get mesh info:', infoError.message); + } + + } catch (error) { + console.warn('LoRa info error:', error.message); + loraInfo.connected = false; + } + + setTimeout(() => loraInfo = null, 60000) + return loraInfo; +} + +// Main status generation function - matches frontend expectations +async function generateStatus() { + try { + console.log('Generating fresh status...'); + + const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([ + getSystemInfo(), + getGPSInfo(), + getNetworkInfo(), + getLoRaInfo() + ]); + + // Format exactly as the frontend expects + const status = { + system: { + temperature: systemInfo.temperature, + load: systemInfo.load, + cpu: systemInfo.cpu, + memory: systemInfo.memory, + storage: systemInfo.storage + }, + gps: { + fix: gpsInfo.fix, + satellites: gpsInfo.satellites, + latitude: gpsInfo.latitude, + longitude: gpsInfo.longitude, + altitude: gpsInfo.altitude + }, + network: { + wifi: networkInfo.wifi, + ethernet: networkInfo.ethernet + }, + lora: loraInfo, + time: new Date().toISOString() + }; + + return status; + } catch (error) { + console.error('Status generation error:', error); + throw error; + } +} + +router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname })); + +// Main status endpoint that matches frontend expectations +router.get('/status', async (req, res) => { + try { + const now = Date.now(); + + // Return cached status if within TTL + if (statusCache && (now - lastUpdate) < CACHE_TTL) { + console.log('Returning cached status'); + return res.json(statusCache); + } + + // Generate fresh status + statusCache = await generateStatus(); + lastUpdate = now; + + console.log('Status updated successfully'); + res.json(statusCache); + } catch (error) { + console.error('Status API error:', error); + res.status(500).json({ + error: 'Failed to get system status', + message: error.message, + timestamp: new Date().toISOString() + }); + } +}); + +// Individual debug endpoints +router.get('/system', async (req, res) => { + try { + const systemInfo = await getSystemInfo(); + res.json(systemInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/gps', async (req, res) => { + try { + const gpsInfo = await getGPSInfo(); + res.json(gpsInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/network', async (req, res) => { + try { + const networkInfo = await getNetworkInfo(); + res.json(networkInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Add individual LoRa endpoint +router.get('/lora', async (req, res) => { + try { + const loraInfo = await getLoRaInfo(); + res.json(loraInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Initialize GPS on startup +initGPS().catch(err => console.warn('GPS initialization failed:', err.message)); + +export { router as statusRouter }; diff --git a/docs/library.md b/docs/library.md new file mode 100755 index 0000000..e69de29 diff --git a/docs/maps.md b/docs/maps.md new file mode 100755 index 0000000..de1686f --- /dev/null +++ b/docs/maps.md @@ -0,0 +1,114 @@ +# Maps + +MRS comes with a self-hosted tile server to provide offline maps using [TileServer GL](https://github.com/maptiler/tileserver-gl). + +While everything comes pre-configured this guide will walkthrough the setup + integration. + +## Table of Contents +- [MRS](../README.md) + - [Maps](#maps) + - [Usage]() + - [Adding Maps]() + - [Adding Contours]() + +## Usage + +The TileServer can be viewed at http://localhost:5000 + +Other applications can use the tileserver by configure the application to use: +- **Raster**: http://localhost:8080/data/{id}/{z}/{x}/{y}.png +- **Vector**: http://localhost:8080/data/{id}/{z}/{x}/{y}.pbf + + +### Commands +- **Setup:** `docker run -d --name maps --restart unless-stopped -p 5000:8080 -v /media/maps/tiles:/data -v /media/maps/styles:/styles maptiler/tileserver-gl:v4.11.0` +- **Restart:** `docker restart maps` +- **Start:** `docker start maps` +- **Stop:** `docker stop maps` + +## Adding Maps / Styles + +TileServer GL works with both vector & raster map tiles. +1. [Download](https://www.maptiler.com/on-prem-datasets/planet/) map tiles +2. Upload tiles file to `/media/maps/tiles` +3. Upload styles to `/media/maps/styles` +4. Reboot TileServer GL: `docker restart maps` + +## Adding Contours + +All topographic maps I have found cost thousands of dollars. With that in mind, these instructions will compile our own topographic layer we can add to any tileset. + +### 1. 📁 Download Topographic Data + +Topography data sets can be downloaded from [OpenTopography](https://portal.opentopography.org/dataCatalog). + +Follow the instructions to sign up & download **GEBCO Global Bathymetry and Topography** + +### 2. 📦 Tooling Setup + +```bash +sudo apt update +sudo apt install -y gdal-bin build-essential sqlite3 libsqlite3-dev git wget unzip docker.io zlib1g-dev +git clone https://github.com/mapbox/tippecanoe.git +cd tippecanoe +make -j$(nproc) +sudo make install +cd .. +``` + +### 3. 🌄 Generate Global 50 m Contours + +> This step is heavy, dont run on Pi — expect 60–90 GB raw GeoJSON before being compressed to ~2-4 GB + +```bash +gdal_contour -f GeoJSONSeq + -a ele \ # elevation attribute + -i 50 \ # interval (50 m) + GEBCOIceTopo.vrt global_50.geojsonl + +tippecanoe -o contours_50m.mbtiles \ + -Z0 -z12 \ # min/max zoom levels + --drop-densest-as-needed \ # keep tiles small + --extend-zooms-if-still-dropping \ # avoid data loss + --layer=contour \ # layer name + --read-parallel --reorder \ # faster + optimized + global_50.geojsonl +``` + +### 4. 🎨 Add contours to style + +Create / Modify a style.json +```json +{ + "version": 8, + "name": "Terrain", + "sources": { + ... + "contours": { + "type": "vector", + "url": "mbtiles://contours_50m.mbtiles" + } + }, + "layers": [ + ... + { + "id": "contours-major", + "type": "line", + "source": "contours", + "source-layer": "contour", + "minzoom": 0, + "maxzoom": 6, + "filter": [">=", "ele", 1000], + "paint": { "line-color": "#888", "line-width": 0.4 } + }, + { + "id": "contours-detailed", + "type": "line", + "source": "contours", + "source-layer": "contour", + "minzoom": 7, + "paint": { "line-color": "#666", "line-width": 0.3 } + } + ] +} +``` diff --git a/docs/setup.md b/docs/setup.md new file mode 100755 index 0000000..ebc8bd9 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,76 @@ +# Setup + +### Hardware +- Raspberry Pi 5 + - 4 GB - No AI + - 8 GB - Small AI + - 16GB - Medium AI +- SD card (recommended 500 GB - 1 TB) + - Gutenberg Project (~80 GB) + - Khan Academy (~180 GB) + - Satellite Maps (150-300 GB) + - Vector Maps (60 - 100 GB) + - Wikipedia (~120 GB) +- [Duel M.2 NVMe hat](https://www.amazon.ca/dp/B0D83784HS?ref=ppx_yo2ov_dt_b_fed_asin_title) +- [M.2 A+E key to M key adapter](https://www.amazon.ca/dp/B0CP7F55S8?ref=ppx_yo2ov_dt_b_fed_asin_title) +- [Intel AX210 WiFi modem](https://www.amazon.ca/dp/B0B39631G1?ref=ppx_yo2ov_dt_b_fed_asin_title) +- [M.2 HaLow modem](https://www.seeedstudio.com/Wio-WM6180-Wi-Fi-HaLow-mini-PCIe-Module-p-6394.html?srsltid=AfmBOopb8Phtr-IBBvYRn5AB5NQNuGIK2tC3we4ceaA_0QiX1JD78-Qs) +- BE-180 GPS Module or [BE-220 GPS Module](https://www.amazon.ca/Geekstory-Navigation-38400bps-Raspberry-Controller/dp/B0BV2MV2GN/ref=sr_1_16_sspa?crid=3N5GO07PGL65Y&dib=eyJ2IjoiMSJ9.Co3Uo8H4Xww9Sh693R4fJJokreDApWile1X8epOqTfZqvQbix_mZr566DdLs_cTuWwurRGQZHlt2nFhdAiW4SZZ1HvsdLlBsqUWOEF7gav47G8Z6xFNT97E1jQLuc0j-PuSRGgMPd0gktZenNz4PkQ.Jma35qmqkNfqByw4Rdk4N7H0eM4CILfcf67G4gV2CD4&dib_tag=se&keywords=gms%2Buart&qid=1758982047&sprefix=gms%2Buart%2Caps%2C84&sr=8-16-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9idGY&th=1) +- [Lora WisBlock Mini](https://www.amazon.ca/RAKwireless-WisBlock-Meshtastic-Starter-RAK19003/dp/B0DFMMTQZM/ref=sr_1_3?crid=QS5X4FYBC5H4&dib=eyJ2IjoiMSJ9.yzgzN2wMOGRfrwlfFeUwsCia4FERbPXPTBnq2WddC2An9Ni_Z2GAgGhquS96QFbSDzvlGu2X070zss9ggbEKGSLgrvRwvmF_mueX5aukwK_xB3S0VFJCsagWRIXdIIrX3k5cF0Seo_NoyuhmfD5wZkR9b0MInx5oRSrJ37Pd0BjERXpxZ3udlIJYf4mBgaGjNDrWc5rYu31deOGOdBfD54lbsrogePm4lb1nhz5Bs81wZTuCCDIm7V0QzO-2mq3Aj2_TLjY-F4F09C1zUm7_fYs9bGPEFmZ6N-syZFNoV6A.oUArviz-pLwgnF9wPrvf-MNKLIyQxwe0hy7uWhXqOVo&dib_tag=se&keywords=wisblock&qid=1758982932&s=electronics&sprefix=wisblock%2Celectronics%2C79&sr=1-3&th=1) +- [E-ink display & momentary switch](https://www.amazon.ca/dp/B0D5DGZXG3?ref=ppx_yo2ov_dt_b_fed_asin_title) +- Rotary Encoder +- USB C 4-pin breakout + 22 AWG wire +- 4x SMA connectors + pigtails 6" +- *Optional: Power bank - Off grid power* +- *Optional: Copper tape - Increases reception* +- *Optional: [SDR](https://www.amazon.ca/dp/B0747LVW59/ref=sspa_dk_detail_1?psc=1&pd_rd_i=B0747LVW59&pd_rd_w=N68sx&content-id=amzn1.sym.516c2169-755e-413a-a38a-68230f4ab66f&pf_rd_p=516c2169-755e-413a-a38a-68230f4ab66f&pf_rd_r=Z9QJD2SRSYTG0HNFHKWP&pd_rd_wg=NBvG6&pd_rd_r=32aa78ce-63df-481d-bc40-f802b5dbd8f2&sp_csd=d2lkZ2V0TmFtZT1zcF9kZXRhaWw) - RF/ADS-B Monitoring* +- *Optional: [Extra WiFi Dongle](https://www.amazon.ca/dp/B01GC8XH0S?ref=ppx_yo2ov_dt_b_fed_asin_title) - Wireless monitoring without sacrificing mesh or AP* +- *Optional: [NRF24LU1 2.4GHz transceiver](https://www.amazon.ca/dp/B09GCLCWYL?ref=ppx_yo2ov_dt_b_fed_asin_title) - HID hacking* + + +### Instructions +1) Flash your wisblock with meshtastic: https://flasher.meshtastic.org/ +2) Flash SD card with Raspbian, ensure Hostname/User/Wifi/SSH is setup +3) Configure OS/Kernal & reboot: `raspi-config` + 1) Update + 2) Interface Options > SSH > Enable + 3) Interface Options > VNC > Disable + 4) Interface Options > SPI > Enable + 5) Interface Options > Serial Port > Disable Login Shell > Enable Hardware + 6) Advanced > Wayland > X11 + 7) Advanced > Expand Filesystem +4) Run setup script: 'chmod +x setup.sh && ./setup.sh' +5) Configure Guacamole + 1) Login: `guacadmin/guacadmin` + 2) guacadmin > settings + 3) Users > Create user & delete guacadmin + 4) Connections + 1) Desktop + - Name: `Desktop` + - Protocol: `RDP` + - Network > Host: `127.0.0.1` + - Network > Port: `3389` + - Authentication > Security mode: `RDP Encryption` + - Performance > Disable Graphics Pipeline Extension: `true` + - *Optional: username / password* + 2) Terminal + - Name: `Terminal` + - Protocol: `SSH` + - Network > Host: `127.0.0.1` + - Network > Port: `22` + - *Optional: username / password* +6) Update filebrowser password +7) Create open-webui admin account & allow registration +8) Create element admin account +9) Download [Kiwix](https://library.kiwix.com) zims + +## + +## TODO +- Create a captive portal with onboarding, PWA, links to all tools +- Create settings page for managing lora, mesh, wifi & AP +- Create a proxy for the [kiwix library](https://library.kiwix.org) that downloads zim files straight to the archive +- Also create a page for making custom zim files and uploading them straight to the archive +```bash +docker run --rm -it -v $(pwd)/output:/output openzim/zimit https://example.com mysite +``` diff --git a/maps/config.json b/maps/config.json new file mode 100755 index 0000000..bbcb201 --- /dev/null +++ b/maps/config.json @@ -0,0 +1,17 @@ +{ + "options": { + "paths": { + "root": "/data", + "styles": "/styles" + } + }, + "styles": { + "dark": { "style": "/styles/dark.json" }, + "light": { "style": "/styles/light.json" } + }, + "data": { + "contours": { "mbtiles": "/data/contours_50m.mbtiles" }, + "osm": { "mbtiles": "/data/osm.mbtiles" }, + "satellite": { "mbtiles": "/data/satellite-2017.mbtiles" } + } +} diff --git a/maps/dark.json b/maps/dark.json new file mode 100755 index 0000000..143d168 --- /dev/null +++ b/maps/dark.json @@ -0,0 +1,381 @@ +{ + "version": 8, + "name": "Dark Topographic", + "sources": { + "osm": { + "type": "vector", + "url": "mbtiles://osm" + }, + "contours": { + "type": "vector", + "url": "mbtiles://contours" + } + }, + "glyphs": "/fonts/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { "background-color": "rgb(12,12,12)" } + }, + { + "id": "water", + "type": "fill", + "source": "osm", + "source-layer": "water", + "paint": { "fill-color": "rgb(27,27,29)" } + }, + { + "id": "residential", + "type": "fill", + "source": "osm", + "source-layer": "landuse", + "filter": ["==", "class", "residential"], + "paint": { "fill-color": "hsl(0,2%,5%)", "fill-opacity": 0.4 } + }, + { + "id": "landcover-wood", + "type": "fill", + "source": "osm", + "source-layer": "landcover", + "filter": ["==", "class", "wood"], + "paint": { "fill-color": "rgb(170,170,170)", "fill-opacity": 0.075 } + }, + { + "id": "landcover-wetland", + "type": "fill", + "source": "osm", + "source-layer": "landcover", + "filter": ["==", "class", "wetland"], + "paint": { "fill-color": "rgb(150,150,150)", "fill-opacity": 0.075 } + }, + { + "id": "parks", + "type": "fill", + "source": "osm", + "source-layer": "landuse", + "filter": ["==", "class", "park"], + "paint": { + "fill-color": "rgb(0,180,0)", + "fill-opacity": 0.075, + "fill-outline-color": "rgb(0,120,0)" + } + }, + { + "id": "roads-small", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["in", "class", "residential", "service", "track", "path"], + "minzoom": 5, + "paint": { + "line-color": "#1f1f1f", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 5, 0.15, + 7, 0.25, + 12, 0.6, + 16, 1 + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 5, 0.1, + 7, 0.2, + 12, 0.6 + ] + } + }, + { + "id": "roads-local", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["==", "class", "minor"], + "minzoom": 6, + "paint": { + "line-color": "#2a2a2a", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.15, + 8, 0.3, + 12, 0.8, + 16, 1.2 + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.1, + 8, 0.3, + 12, 0.6 + ] + } + }, + { + "id": "roads-primary", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["in", "class", "primary", "secondary"], + "minzoom": 7, + "paint": { + "line-color": "#333333", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 7, 0.3, + 12, 1.5, + 16, 3 + ] + } + }, + { + "id": "roads-motorway", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["in", "class", "motorway", "trunk"], + "minzoom": 6, + "paint": { + "line-color": "#555555", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.5, + 10, 2, + 14, 5 + ] + } + }, + { + "id": "boundary-country", + "type": "line", + "source": "osm", + "source-layer": "boundary", + "filter": ["all", ["==", "admin_level", 2], ["!=", "maritime", 1]], + "paint": { + "line-color": "#888", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 0, 0.5, + 4, 1.5, + 6, 2 + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 0, 0.3, + 3, 0.6, + 5, 0.8 + ] + } + }, + { + "id": "boundary-state", + "type": "line", + "source": "osm", + "source-layer": "boundary", + "filter": ["all", ["==", "admin_level", 4], ["!=", "maritime", 1]], + "minzoom": 2, + "paint": { + "line-color": "#666", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 2, 0.5, + 5, 1, + 8, 2 + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 2, 0, + 4, 0.5, + 6, 0.8 + ] + } + }, + { + "id": "boundary-local", + "type": "line", + "source": "osm", + "source-layer": "boundary", + "filter": ["all", ["==", "admin_level", 6], ["!=", "maritime", 1]], + "minzoom": 6, + "paint": { + "line-color": "#444", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.5, + 10, 1.2 + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 6, 0, + 8, 0.5, + 12, 0.8 + ] + } + }, + { + "id": "buildings", + "type": "fill", + "source": "osm", + "source-layer": "building", + "minzoom": 14, + "paint": { + "fill-color": "#222222", + "fill-opacity": 0.7 + } + }, + { + "id": "places", + "type": "symbol", + "source": "osm", + "source-layer": "place", + "minzoom": 2, + "layout": { + "text-size": 12, + "text-font": ["Noto Sans Regular"], + "text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]] + }, + "filter": ["!=", "class", "continent"], + "paint": { + "text-color": "rgba(200,200,200,0.4)", + "text-halo-color": "rgba(0,0,0,0.6)", + "text-halo-width": 2 + } + }, + { + "id": "road-labels", + "type": "symbol", + "source": "osm", + "source-layer": "transportation_name", + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": [ + "interpolate", ["linear"], ["zoom"], + 13, 10, + 16, 14 + ] + }, + "paint": { + "text-color": "rgba(200,200,200,0.4)", + "text-halo-color": "rgba(0,0,0,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "poi-labels", + "type": "symbol", + "source": "osm", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": [ + "interpolate", ["linear"], ["zoom"], + 14, 9, + 18, 13 + ] + }, + "paint": { + "text-color": "rgba(200,200,200,0.4)", + "text-halo-color": "rgba(0,0,0,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "airport-labels", + "type": "symbol", + "source": "osm", + "source-layer": "aeroway", + "minzoom": 14, + "layout": { + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": 12 + }, + "paint": { + "text-color": "rgba(200,200,200,0.4)", + "text-halo-color": "rgba(0,0,0,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "rail-labels", + "type": "symbol", + "source": "osm", + "source-layer": "transportation", + "filter": ["==", "class", "rail"], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": 11 + }, + "paint": { + "text-color": "rgba(200,200,200,0.4)", + "text-halo-color": "rgba(0,0,0,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "natural-labels", + "type": "symbol", + "source": "osm", + "source-layer": "natural", + "minzoom": 14, + "layout": { + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": 11 + }, + "paint": { + "text-color": "rgba(200,200,200,0.4)", + "text-halo-color": "rgba(0,0,0,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "house-numbers", + "type": "symbol", + "source": "osm", + "source-layer": "housenumber", + "minzoom": 15, + "layout": { + "text-field": "{housenumber}", + "text-font": ["Noto Sans Regular"], + "text-size": [ + "interpolate", ["linear"], ["zoom"], + 15, 10, + 29, 14 + ] + }, + "paint": { + "text-color": "rgba(200,200,200,0.2)", + "text-halo-color": "rgba(0,0,0,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "contours-line", + "type": "line", + "source": "contours", + "source-layer": "contour", + "minzoom": 10, + "paint": { + "line-color": "#555555", + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 10, 0.2, + 14, 0.6 + ], + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 10, 0.5, + 14, 1.5 + ] + } + } + ], + "id": "dark-matter-slim" +} diff --git a/maps/light.json b/maps/light.json new file mode 100755 index 0000000..1b4c16b --- /dev/null +++ b/maps/light.json @@ -0,0 +1,368 @@ +{ + "version": 8, + "name": "Light Topographic", + "sources": { + "osm": { + "type": "vector", + "url": "mbtiles://osm" + }, + "contours": { + "type": "vector", + "url": "mbtiles://contours" + } + }, + "glyphs": "/fonts/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { "background-color": "hsl(47, 26%, 88%)" } + }, + { + "id": "water", + "type": "fill", + "source": "osm", + "source-layer": "water", + "paint": { "fill-color": "hsl(205, 56%, 73%)" } + }, + { + "id": "residential", + "type": "fill", + "source": "osm", + "source-layer": "landuse", + "filter": ["==", "class", "residential"], + "paint": { "fill-color": "hsl(47, 13%, 86%)", "fill-opacity": 0.7 } + }, + { + "id": "landcover-wood", + "type": "fill", + "source": "osm", + "source-layer": "landcover", + "filter": ["==", "class", "wood"], + "paint": { + "fill-color": "hsl(82, 46%, 72%)", + "fill-opacity": { "base": 1, "stops": [[8, 0.6], [22, 1]] } + } + }, + { + "id": "landcover-wetland", + "type": "fill", + "source": "osm", + "source-layer": "landcover", + "filter": ["==", "class", "wetland"], + "paint": { "fill-color": "hsl(82, 46%, 72%)", "fill-opacity": 0.45 } + }, + { + "id": "parks", + "type": "fill", + "source": "osm", + "source-layer": "landuse", + "filter": ["==", "class", "park"], + "paint": { + "fill-color": "#E1EBB0", + "fill-opacity": { "base": 1, "stops": [[5, 0], [9, 0.75]] } + } + }, + { + "id": "roads-small", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["in", "class", "residential", "service", "track", "path"], + "minzoom": 5, + "paint": { + "line-color": "hsl(0, 0%, 97%)", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 5, 0.15, + 7, 0.25, + 12, 0.6, + 16, 1 + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 5, 0.1, + 7, 0.2, + 12, 0.6 + ] + } + }, + { + "id": "roads-local", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["==", "class", "minor"], + "minzoom": 6, + "paint": { + "line-color": "hsl(0, 0%, 97%)", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.15, + 8, 0.3, + 12, 0.8, + 16, 1.2 + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.1, + 8, 0.3, + 12, 0.6 + ] + } + }, + { + "id": "roads-primary", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["in", "class", "primary", "secondary"], + "minzoom": 7, + "paint": { + "line-color": "#fff", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 7, 0.3, + 12, 1.5, + 16, 3 + ] + } + }, + { + "id": "roads-motorway", + "type": "line", + "source": "osm", + "source-layer": "transportation", + "filter": ["in", "class", "motorway", "trunk"], + "minzoom": 6, + "paint": { + "line-color": "hsl(0, 0%, 100%)", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.5, + 10, 2, + 14, 5 + ] + } + }, + { + "id": "boundary-country", + "type": "line", + "source": "osm", + "source-layer": "boundary", + "filter": ["all", ["==", "admin_level", 2], ["!=", "maritime", 1]], + "paint": { + "line-color": "hsl(0, 0%, 60%)", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 0, 0.5, + 4, 1.5, + 6, 2 + ], + "line-opacity": 0.5 + } + }, + { + "id": "boundary-state", + "type": "line", + "source": "osm", + "source-layer": "boundary", + "filter": ["all", ["==", "admin_level", 4], ["!=", "maritime", 1]], + "minzoom": 2, + "paint": { + "line-color": "hsla(0, 0%, 60%, 0.5)", + "line-dasharray": [2, 1], + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 2, 0.5, + 5, 1, + 8, 2 + ] + } + }, + { + "id": "boundary-local", + "type": "line", + "source": "osm", + "source-layer": "boundary", + "filter": ["all", ["==", "admin_level", 6], ["!=", "maritime", 1]], + "minzoom": 6, + "paint": { + "line-color": "#bbbbbb", + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 6, 0.5, + 10, 1.2 + ], + "line-opacity": 0.5 + } + }, + { + "id": "buildings", + "type": "fill", + "source": "osm", + "source-layer": "building", + "minzoom": 14, + "paint": { + "fill-color": "rgba(222, 211, 190, 1)", + "fill-opacity": { "base": 1, "stops": [[13, 0], [15, 1]] } + } + }, + { + "id": "places", + "type": "symbol", + "source": "osm", + "source-layer": "place", + "minzoom": 2, + "filter": ["!=", "class", "continent"], + "layout": { + "text-size": 12, + "text-font": ["Noto Sans Regular"], + "text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]] + }, + "paint": { + "text-color": "#444", + "text-halo-color": "#ffffff", + "text-halo-width": 2 + } + }, + { + "id": "road-labels", + "type": "symbol", + "source": "osm", + "source-layer": "transportation_name", + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": [ + "interpolate", ["linear"], ["zoom"], + 13, 10, + 16, 14 + ] + }, + "paint": { + "text-color": "#444", + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-labels", + "type": "symbol", + "source": "osm", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": [ + "interpolate", ["linear"], ["zoom"], + 14, 9, + 18, 13 + ] + }, + "paint": { + "text-color": "rgba(100,100,100,0.6)", + "text-halo-color": "rgba(255,255,255,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "airport-labels", + "type": "symbol", + "source": "osm", + "source-layer": "aeroway", + "minzoom": 14, + "layout": { + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": 12 + }, + "paint": { + "text-color": "rgba(100,100,100,0.6)", + "text-halo-color": "rgba(255,255,255,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "rail-labels", + "type": "symbol", + "source": "osm", + "source-layer": "transportation", + "filter": ["==", "class", "rail"], + "minzoom": 14, + "layout": { + "symbol-placement": "line", + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": 11 + }, + "paint": { + "text-color": "rgba(100,100,100,0.6)", + "text-halo-color": "rgba(255,255,255,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "natural-labels", + "type": "symbol", + "source": "osm", + "source-layer": "natural", + "minzoom": 14, + "layout": { + "text-field": ["coalesce", ["get", "name:en"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": 11 + }, + "paint": { + "text-color": "rgba(100,100,100,0.6)", + "text-halo-color": "rgba(255,255,255,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "house-numbers", + "type": "symbol", + "source": "osm", + "source-layer": "housenumber", + "minzoom": 15, + "layout": { + "text-field": "{housenumber}", + "text-font": ["Noto Sans Regular"], + "text-size": [ + "interpolate", ["linear"], ["zoom"], + 15, 10, + 29, 14 + ] + }, + "paint": { + "text-color": "rgba(100,100,100,0.5)", + "text-halo-color": "rgba(255,255,255,0.6)", + "text-halo-width": 1 + } + }, + { + "id": "contours-line", + "type": "line", + "source": "contours", + "source-layer": "contour", + "minzoom": 10, + "paint": { + "line-color": "#999", + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 10, 0.2, + 14, 0.5 + ], + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 10, 0.5, + 14, 1.2 + ] + } + } + ], + "id": "light-matter-basic" +} diff --git a/public/favicon.png b/public/favicon.png new file mode 100755 index 0000000..4e81999 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100755 index 0000000..26e5733 --- /dev/null +++ b/public/index.html @@ -0,0 +1,939 @@ + + + + MRS Dashboard + + + + + + + +
+
+
+ Logo +
+ +
Hostname
+
+
+
+
+
+
Local
+
00:00:00
+
Jan 1, 2025
+
+
+
Zulu
+
00:00:00
+
Jan 1, 2025
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + +
+
+ + + + + + +
+
+ + + + +
+
+ + + +
+
+
+
+ +
+
+
+
+ + + + + + + + +
System ?°C
+
+
+
+
CPU - Load
+
+
+
0%
+
+
+
+
Memory
+
+
+
0%
+
+
+
+
Disk
+
+
+
0%
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + +
GPS
+
+
+
39.2000, -49.0000
+
No Fix
+
+
+ +
+
+ + + + + +
LoRa
+
+
+
0 / 0 Online
+
Ch 0% Air 0%
+
+
+ +
+
+ + + + + + + + + + + + + + + + +
Mesh
+
+
+
Connected
+
900 Mhz -45 dBm
+
+
+ +
+
+ + + + + + +
WiFi
+
+
+
Access Point
+
SSID: Hostname
+
+
+ +
+
+ + + + +
Ethernet
+
+
+
Disconnected
+
No cable detected
+
+
+ +
+
+
+ +
+ + + +
+ + + + diff --git a/public/index.js b/public/index.js new file mode 100755 index 0000000..f30310f --- /dev/null +++ b/public/index.js @@ -0,0 +1,368 @@ + +class Dashboard { + constructor() { + this.statusCache = null; + this.lastFetch = 0; + this.hostname = 'localhost'; + this.serverTimeOffset = null; // Track difference between server and client time + this.isTimeServerSynced = false; // Track if we're using server time + this.init(); + } + + async init() { + await this.fetchHostname(); + this.updateHostname(); + this.startTimeUpdates(); // Start with client time immediately + this.startStatusUpdates(); + this.initializeEventListeners(); + } + + async fetchHostname() { + try { + const response = await fetch('/api/hostname'); + const data = await response.json(); + this.hostname = data.hostname; + } catch (error) { + console.warn('Failed to fetch hostname:', error); + } + } + + updateHostname() { + const hostnameElement = document.querySelector('.hostname'); + if (hostnameElement) { + hostnameElement.textContent = this.hostname; + } + } + + async fetchStatus() { + try { + // Record start time for latency calculation + const requestStart = performance.now(); + const response = await fetch('/api/status'); + const requestEnd = performance.now(); + + if (!response.ok) throw new Error('Status fetch failed'); + + const status = await response.json(); + this.statusCache = status; + this.updateUI(status, requestStart, requestEnd); + } catch (error) { + console.error('Failed to fetch status:', error); + this.showOfflineStatus(); + } + } + + updateUI(status, requestStart, requestEnd) { + this.updateSystemMetrics(status.system); + this.updateGPSStatus(status.gps); + this.updateNetworkStatus(status.network); + this.updateLoRaStatus(status.lora); + this.updateTimeSync(status.time, requestStart, requestEnd); + } + + updateSystemMetrics(system) { + // Update temperature + const tempElement = document.getElementById('temp'); + if (tempElement && system.temperature) { + tempElement.textContent = `${system.temperature}°C`; + } + + // Update load average + const loadElement = document.getElementById('load'); + if (loadElement && system.load !== undefined) { + loadElement.textContent = `${system.load.toFixed(1)} Load`; + } + + // Update progress bars + this.updateProgressBar('cpu-usage', system.cpu); + this.updateProgressBar('memory-usage', system.memory); + this.updateProgressBar('disk-usage', system.storage); + } + + updateProgressBar(id, percentage) { + const fillElement = document.getElementById(`${id}-fill`); + const textElement = document.getElementById(`${id}-text`); + + if (fillElement && textElement) { + const value = Math.round(percentage); + fillElement.style.width = `${value}%`; + textElement.textContent = `${value}%`; + + // Update color based on usage + if (value > 80) { + fillElement.style.background = '#ef4444'; // Red + } else if (value > 60) { + fillElement.style.background = '#f59e0b'; // Orange + } else { + fillElement.style.background = '#10b981'; // Green + } + } + } + + updateGPSStatus(gps) { + const gpsStatus = document.getElementById('gps-status'); + const gpsTile = document.getElementById('gps-tile'); + + if (gpsStatus && gpsTile) { + const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null; + + // Update status indicator + if (hasValidFix) { + gpsStatus.classList.remove('disconnected'); + gpsStatus.classList.add('connected'); + gpsTile.classList.remove('disconnected'); + gpsTile.classList.add('connected'); + } else { + gpsStatus.classList.remove('connected'); + gpsStatus.classList.add('disconnected'); + gpsTile.classList.remove('connected'); + gpsTile.classList.add('disconnected'); + } + + // Update GPS tile info + const gpsInfo = gpsTile.querySelector('.tile-info'); + const gpsDetail = gpsTile.querySelector('.tile-detail'); + + if (gpsInfo && gpsDetail) { + if (hasValidFix) { + gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`; + gpsDetail.textContent = `${gps.fix} • ${gps.satellites} sats • Alt: ${Math.round(gps.altitude)}m`; + } else { + gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Signal'; + gpsDetail.textContent = `${gps.satellites} satellites`; + } + } + } + } + + updateNetworkStatus(network) { + // Update WiFi status + const wifiTile = document.getElementById('wifi-tile'); + if (wifiTile) { + const wifiInfo = wifiTile.querySelector('.tile-info'); + const wifiDetail = wifiTile.querySelector('.tile-detail'); + + if (wifiInfo && wifiDetail) { + if (network.wifi.mode === 'ap') { + wifiInfo.textContent = 'Access Point'; + wifiDetail.textContent = `SSID: ${network.wifi.ssid}`; + } else { + wifiInfo.textContent = 'Client Mode'; + wifiDetail.textContent = `SSID: ${network.wifi.ssid}`; + } + } + } + + // Update Ethernet status + const ethernetStatus = document.getElementById('ethernet-status'); + const ethernetTile = document.getElementById('ethernet-tile'); + + if (ethernetStatus && ethernetTile) { + if (network.ethernet.connected) { + ethernetStatus.classList.remove('disconnected'); + ethernetStatus.classList.add('connected'); + ethernetTile.classList.remove('disconnected'); + ethernetTile.classList.add('connected'); + + const ethernetInfo = ethernetTile.querySelector('.tile-info'); + const ethernetDetail = ethernetTile.querySelector('.tile-detail'); + if (ethernetInfo && ethernetDetail) { + ethernetInfo.textContent = 'Connected'; + ethernetDetail.textContent = 'Link detected'; + } + } else { + ethernetStatus.classList.remove('connected'); + ethernetStatus.classList.add('disconnected'); + ethernetTile.classList.remove('connected'); + ethernetTile.classList.add('disconnected'); + + const ethernetInfo = ethernetTile.querySelector('.tile-info'); + const ethernetDetail = ethernetTile.querySelector('.tile-detail'); + if (ethernetInfo && ethernetDetail) { + ethernetInfo.textContent = 'Disconnected'; + ethernetDetail.textContent = 'No cable detected'; + } + } + } + } + + updateLoRaStatus(lora) { + const loraStatus = document.getElementById('lora-status'); + const loraTile = document.getElementById('lora-tile'); + + if (loraStatus && loraTile) { + if (lora.connected) { + loraStatus.classList.remove('disconnected'); + loraStatus.classList.add('connected'); + loraTile.classList.remove('disconnected'); + loraTile.classList.add('connected'); + + const loraInfo = loraTile.querySelector('.tile-info'); + const loraDetail = loraTile.querySelector('.tile-detail'); + + if (loraInfo && loraDetail) { + loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`; + loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`; + } + } else { + loraStatus.classList.remove('connected'); + loraStatus.classList.add('disconnected'); + loraTile.classList.remove('connected'); + loraTile.classList.add('disconnected'); + + const loraInfo = loraTile.querySelector('.tile-info'); + const loraDetail = loraTile.querySelector('.tile-detail'); + + if (loraInfo && loraDetail) { + loraInfo.textContent = 'Disconnected'; + loraDetail.textContent = 'No device found'; + } + } + } + } + + updateTimeSync(serverTimeString, requestStart, requestEnd) { + if (serverTimeString && requestStart && requestEnd) { + // Calculate network latency (round-trip time / 2) + const networkLatency = (requestEnd - requestStart) / 2; + + // Parse server time and adjust for network latency + const serverTime = new Date(serverTimeString); + const adjustedServerTime = new Date(serverTime.getTime() + networkLatency); + + // Calculate the offset between adjusted server time and client time + const clientTime = new Date(); + this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime(); + this.isTimeServerSynced = true; + + console.log(`Time synchronized with server (latency: ${networkLatency.toFixed(1)}ms, offset: ${this.serverTimeOffset.toFixed(1)}ms)`); + } + + // Update time displays + this.updateCurrentTime(); + } + + updateCurrentTime() { + const now = new Date(); + let displayTime; + + if (this.isTimeServerSynced && this.serverTimeOffset !== null) { + // Use server-synchronized time + displayTime = new Date(now.getTime() + this.serverTimeOffset); + } else { + // Use client time (default) + displayTime = now; + } + + this.updateTimeDisplay('local', displayTime, false); + this.updateTimeDisplay('zulu', displayTime, true); + } + + updateTimeDisplay(type, date, isUtc) { + const timeElement = document.getElementById(`${type}-time`); + const dateElement = document.getElementById(`${type}-date`); + + if (timeElement && dateElement) { + const displayDate = isUtc ? new Date(date.getTime()) : date; + + const timeStr = isUtc ? + displayDate.toUTCString().split(' ')[4] : + displayDate.toLocaleTimeString(); + + const dateStr = isUtc ? + displayDate.toUTCString().split(' ').slice(0, 4).join(' ') : + displayDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + + timeElement.textContent = timeStr; + dateElement.textContent = dateStr; + + // Color time orange when using client time, white when server-synced + if (this.isTimeServerSynced) { + timeElement.style.color = '#fff'; + dateElement.style.color = '#64748b'; + } else { + timeElement.style.color = '#f59e0b'; + dateElement.style.color = '#f59e0b'; + } + } + } + + showOfflineStatus() { + // Reset to client time and show orange color + this.serverTimeOffset = null; + this.isTimeServerSynced = false; + console.warn('System appears offline - falling back to client time'); + } + + startStatusUpdates() { + // Initial fetch + this.fetchStatus(); + + // Update every 60 seconds + setInterval(() => { + this.fetchStatus(); + }, 60000); + } + + startTimeUpdates() { + // Start immediately with client time + this.updateCurrentTime(); + + // Update time every second + setInterval(() => { + this.updateCurrentTime(); + }, 1000); + } + + initializeEventListeners() { + // Expand/collapse status + const expandToggle = document.getElementById('expand-toggle'); + const expandedStatus = document.getElementById('expanded-status'); + const expandIcon = document.getElementById('expand-icon'); + + if (expandToggle && expandedStatus && expandIcon) { + expandToggle.addEventListener('click', () => { + expandedStatus.classList.toggle('visible'); + expandIcon.classList.toggle('expanded'); + }); + } + + // Search functionality + const searchInput = document.getElementById('search-input'); + const appsGrid = document.getElementById('apps-grid'); + + if (searchInput && appsGrid) { + searchInput.addEventListener('input', (e) => { + const query = e.target.value.toLowerCase(); + const apps = appsGrid.querySelectorAll('.app-item'); + + apps.forEach(app => { + const name = app.getAttribute('data-name')?.toLowerCase() || ''; + app.style.display = name.includes(query) ? 'flex' : 'none'; + }); + }); + } + } +} + +function openKiwixm(event) { + event.stopPropagation(); + event.preventDefault(); + window.location.href = window.location.origin.replace(/:\d+/g, '') + ':1300/admin'; +} + +// Initialize dashboard when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + new Dashboard(); + + document.querySelectorAll('.app-item[href^=":"]').forEach(link => { + const port = link.getAttribute('href').substring(1); // Remove the ':' + const url = new URL(location.origin); + url.port = port; + link.href = url.toString(); + }); +}); diff --git a/public/manifest.json b/public/manifest.json new file mode 100755 index 0000000..7b9f337 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "MRS", + "short_name": "MRS", + "description": "Multipurpose Radio Server", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "lang": "en-US", + "scope": "/" +} diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..a141165 --- /dev/null +++ b/setup.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Check permissions +if [ "$UID" != "0" ]; then + echo "This script must be run as root / with sudo" >&2 + exit 1 +fi + +# Get settings +read -rsp "User Password: " pass; echo +RID=$(cat /etc/radio_id) +read -rp "Radio ID (${RID:-1-254}): " ip +ip=${ip:-$RID} +echo $ip > /etc/radio_id +read -rp "Radio Name ($HOSTNAME): " name +name=${name:-$HOSTNAME} +read -rp "Mesh Name: " mesh_ssid +read -rsp "Mesh Password: " mesh_pass; echo +read -rsp "Hotspot Password: " ap_pass; echo +read -p "Enable wireguard (Y/N): " wireguard +if [ "${wireguard,,}" == "y" ]; then + while [ ! -f "/etc/wireguard/wg0.conf" ]; do + read -n 1 -s -p "Add '/etc/wireguard/wg0.conf' then press any key to continue..." + done +fi + +# Install updates & packages +export DEBIAN_FRONTEND=noninteractive +apt update && apt upgrade -y && apt install -y \ + build-essential libpcap-dev libusb-1.0-0-dev libnetfilter-queue-dev \ + btop jq tmux unzip zip + +# Update hostname +hostnamectl set-hostname "${name}" +sed -i "s/$HOSTNAME/$name" /etc/hostname +sed -i "s/$HOSTNAME/$name" /etc/hosts + +# Update banner +cat <> /etc/motd + + ██████ ██████ ███████████ █████████ +░░██████ ██████ ░░███░░░░░███ ███░░░░░███ + ░███░█████░███ ░███ ░███ ░███ ░░░ + ░███░░███ ░███ ░██████████ ░░█████████ + ░███ ░░░ ░███ ░███░░░░░███ ░░░░░░░░███ + ░███ ░███ ░███ ░███ ███ ░███ + █████ █████ █████ █████░░█████████ +░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░ + +EOF + +# Setup wireguard +if [ "${wireguard,,}" == "y" ]; then + apt install wireguard -y + systemctl enable wg-quick@wg0 + systemctl start wg-quick@wg0 +fi + +# Setup GPS - gpsmon +sed -i '/^enable_uart=/d' /boot/firmware/config.txt +echo "enable_uart=1" >> /boot/firmware/config.txt +apt install -y gpsd gpsd-clients chrony +tee /etc/default/gpsd > /dev/null <<'EOF' +START_DAEMON="true" +GPSD_OPTIONS="-n" +DEVICES="/dev/serial0" +USBAUTO="false" +GPSD_SOCKET="/var/run/gpsd.sock" +EOF +tee -a /etc/chrony/chrony.conf > /dev/null <<'EOF' +refclock SHM 0 offset 0.5 delay 0.2 refid NMEA +EOF +systemctl enable --now gpsd chrony + +# Setup DNS +# TODO: Setup AP management +apt install -y dnsmasq +tee -a /etc/dnsmasq.d/local.conf > /dev/null <<'EOF' +server=1.1.1.1 +server=1.0.0.1 +address=/$HOSTNAME/$IP +address=/.$HOSTNAME/$IP +EOF + +# Install docker +apt install -y qemu-user-static +curl -fsSL https://get.docker.com | sh +usermod -aG docker $USER +newgrp docker + +# Setup Guacamole - http://localhost:8080 +sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config +systemctl restart ssh +apt-get install -y xrdp +tee /etc/xrdp/startwm.sh > /dev/null <<'EOF' +#!/bin/sh +unset DBUS_SESSION_BUS_ADDRESS +unset XDG_RUNTIME_DIR +exec startlxde-pi +EOF +mkdir -p /etc/polkit-1/rules.d +tee /etc/polkit-1/rules.d/49-nopasswd.rules > /dev/null <<'EOF' +polkit.addRule(function(action, subject) { + return polkit.Result.YES; +}); +EOF +systemctl restart polkit +systemctl enable --now xrdp +docker run -d --name guacamole --network host --restart unless-stopped -v guacamole:/config flcontainers/guacamole:latest + +# Setup Automount + FileBrowser - http://localhost:1200 +mkdir -p /etc/udev/rules.d/ +echo 'ENV{ID_FS_USAGE}=="filesystem", ENV{UDISKS_FILESYSTEM_SHARED}="1"' > /etc/udev/rules.d/99-udisks2.rules +docker run -d --name filebrowser --restart unless-stopped -p 1200:8080 -e FB_USERNAME=$USER -v filebrowser:/config -v /media:/data hurlenko/filebrowser:latest +sleep 3 +fb_pass=$(docker logs filebrowser 2>&1 | grep -oP 'randomly generated password: \K.*'); echo $fb_pass + +# Setup Kiwix - http://localhost:1300 +mkdir -p /media/library/archive +mkdir -p /media/library/zim +docker run -d --name kiwix --restart unless-stopped -p 1300:3000 -v /media/library:/data ztimson/kiwixm:latest + +# Maps - http://localhost:1400 +mkdir -p /media/maps/tiles +mkdir -p /media/maps/styles +docker run -d --name maps --restart unless-stopped -p 1400:8080 -v /media/maps/config.json:/config.json -v /media/maps/tiles:/data -v /media/maps/styles:/styles maptiler/tileserver-gl:v4.11.0 + +# Setup Ollama + OpenWebUI - http://localhost:1000 +docker run -d --name ollama --restart unless-stopped -p 11434:11434 -v ollama:/root/.ollama ollama/ollama:latest +docker exec -d ollama ollama pull llama3.2:1b-instruct-q4_K_M # Works best on Pi5 (High TPS, Low memory) +docker run -d --name open-webui --restart unless-stopped -p 1000:8080 -e OLLAMA_BASE_URL=http://ollama:11434 --link ollama -v open-webui:/app/backend/data ghcr.io/open-webui/open-webui:main + +# LoRa - http://localhost:4403 +pip install --upgrade --break-system-packages meshtastic +LORA_DEVICE=$(ls /dev/serial/by-id | grep RAK) +apt install -y ser2net +rm /etc/ser2net.yaml || echo +tee /etc/ser2net.conf < /etc/element/config.json +docker run -d --name element --restart unless-stopped -p 1500:80 -v /etc/element/config.json:/app/config.json vectorim/element-web:latest + +# Setup pentest suite - http:localhost:1600 +sudo apt install -y arp-scan aircrack-ng gobuster hashcat hcxtools hydra john netcat-openbsd nikto nmap masscan reaver sqlmap tcpdump wireshark +curl https://raw.githubusercontent.com/rapid7/metasploit-omnibus/master/config/templates/metasploit-framework-wrappers/msfupdate.erb > msfinstall +chmod +x msfinstall +./msfinstall +rm msfinstall +docker run -d --name bettercap --restart unless-stopped --privileged --net=host bettercap/bettercap -eval "set api.rest.address 0.0.0.0; set ui.address 0.0.0.0; set ui.port 1600; ui on; set gps.device localhost:2947;" + +# ATAK - http://localhost:1100 (XMPP - 5222, API - 8089) +docker run -d --name atak --restart unless-stopped --platform=linux/amd64 -p 8089:8080 -p 5222:8999 -p 1100:8088 -v atak:/app kdudkov/goatak_server:latest + +# TODO: SDR tools (SDRangel, dump1060) +#docker run -d --name sdrserver --restart unless-stopped --device /dev/bus/usb:/dev/bus/usb -p 8091:8091 -p 8092:8092 f4exb06/sdrangelsrv:v7.22.7 --bind 0.0.0.0 +#docker run -d --name sdrcli --restart unless-stopped -p 1000:8080 -e SDRANGEL_SERVER_URL=http://10.69.5.108:8091 f4exb06/sdrangelcli:v4.0.2 + +# Speedtest - http://localhost:1900 +docker run -d --name speedtest --restart unless-stopped -p 1900:80 lscr.io/linuxserver/librespeed:latest + +# TODO: Captive Portal + PWA & Caddy +# TODO: E-Ink + QR Join AP +# TODO: Mesh diff --git a/src/main.mjs b/src/main.mjs new file mode 100755 index 0000000..ebecdb9 --- /dev/null +++ b/src/main.mjs @@ -0,0 +1,51 @@ + +import express from "express"; +import { join } from 'path'; +import {environment} from './services/environment.mjs'; +import { statusRouter } from './services/status.mjs'; + +(async () => { + const app = express(); + + // Middleware + app.use(express.json()); + app.use((err, req, res, next) => { + console.error('Middleware error:', err); + res.status(500).json({ error: 'Internal server error' }); + }); + + // Routes + console.log(join(environment.root, '../public')); + app.use(express.static(join(environment.root, '../public'))); + + app.use('/api', statusRouter); + + // Error handler + app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); + }); + + // Startup + const server = app.listen(environment.port, () => { + console.log(`Dashboard running on http://localhost:${environment.port}`); + }).on('error', (err) => { + console.error('Server startup error:', err); + process.exit(1); + }); + + // Shutdown + const gracefulShutdown = (signal) => { + console.log(`Received ${signal}, shutting down gracefully`); + server.close((err) => { + if (err) { + console.error('Error during server shutdown:', err); + process.exit(1); + } + console.log('Server closed'); + process.exit(0); + }); + }; + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); +})(); diff --git a/src/services/environment.mjs b/src/services/environment.mjs new file mode 100755 index 0000000..08590a2 --- /dev/null +++ b/src/services/environment.mjs @@ -0,0 +1,38 @@ +import dotenv from 'dotenv'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { execSync } from "child_process"; + +dotenv.config(); + +export const environment = { + config: process.env.CONFIG || 'config.json', + hostname: process.env.HOSTNAME || execSync('hostname', { encoding: "utf-8" }) || 'localhost', + gpsd: process.env.GPSD || 2947, + port: process.env.PORT || 3000, + root: dirname(dirname(fileURLToPath(import.meta.url))), + settings: {}, +} + +if(!environment.config.startsWith('/')) environment.config = `${environment.root}/${environment.config}`; + +export function loadSettings() { + if(!fs.existsSync(environment.config)) fs.writeFileSync(environment.config, '', { flag: 'a' }); + const value = fs.readFileSync(environment.config, 'utf-8'); + try { return JSON.parse(value); } + catch (e) { + return { + lora: { + mode: 'MESH', + telemetry: true, + } + }; + } +} + +export function saveSettings() { + fs.writeFileSync(environment.config, JSON.stringify(environment.settings, null, 4)); +} + +loadSettings(); diff --git a/src/services/status.mjs b/src/services/status.mjs new file mode 100755 index 0000000..7d6f7ee --- /dev/null +++ b/src/services/status.mjs @@ -0,0 +1,426 @@ + +import express from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { readFileSync, existsSync } from 'fs'; +import pkg from 'node-gpsd'; +const { GPS } = pkg; +import {environment} from './environment.mjs'; + +const router = express.Router(); +const execAsync = promisify(exec); + +// Status cache with TTL +let statusCache = null; +let lastUpdate = 0; +const CACHE_TTL = 15 * 1000; // 15 seconds + +// GPS client setup +let gpsClient = null; +let gpsData = { + fix: 'No Fix', + latitude: null, + longitude: null, + altitude: null, + satellites: 0 +}; + +// Initialize GPS connection +async function initGPS() { + try { + gpsClient = new GPS({ hostname: 'localhost', port: 2947 }); + + gpsClient.on('TPV', (data) => { + gpsData.latitude = data.lat || null; + gpsData.longitude = data.lon || null; + gpsData.altitude = data.alt || null; + gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix'; + }); + + gpsClient.on('SKY', (data) => { + gpsData.satellites = (data.satellites || []).length; + }); + + gpsClient.on('error', (err) => { + console.warn('GPS error:', err.message); + }); + + await gpsClient.watch(); + console.log('GPS client initialized'); + } catch (error) { + console.warn('GPS not available:', error.message); + } +} + +// CLI-based system information gathering +async function getSystemInfo() { + const info = { + temperature: 0, + load: 0, + cpu: 0, + memory: 0, + storage: 0 + }; + + try { + // CPU temperature (Raspberry Pi specific) + if (existsSync('/sys/class/thermal/thermal_zone0/temp')) { + const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8'); + info.temperature = Math.round(parseInt(tempData.trim()) / 1000); + } + + // Load average + const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'"); + info.load = parseFloat(loadAvg.trim()) || 0; + + // CPU usage + const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'"); + info.cpu = parseFloat(cpuUsage.trim()) || 0; + + // Memory usage percentage + const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'"); + info.memory = parseFloat(memUsage.trim()) || 0; + + // Disk usage percentage for root filesystem + const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'"); + info.storage = parseFloat(diskUsage.trim()) || 0; + + } catch (error) { + console.warn('System info error:', error.message); + } + + return info; +} + +async function getGPSInfo() { + const gpsInfo = { + satellites: 0, + latitude: null, + longitude: null, + altitude: null + }; + + try { + // Check if gpsd is running + await execAsync('systemctl is-active gpsd'); + + // Try to get GPS data using gpspipe + const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 }); + + if (gpsRaw.trim()) { + const gpsJson = JSON.parse(gpsRaw.trim()); + gpsInfo.latitude = gpsJson.lat || null; + gpsInfo.longitude = gpsJson.lon || null; + gpsInfo.altitude = gpsJson.alt || null; + } + + // Get satellite count + try { + const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 }); + if (skyRaw.trim()) { + const skyJson = JSON.parse(skyRaw.trim()); + gpsInfo.satellites = (skyJson.satellites || []).length; + } + } catch (skyError) { } + } catch (error) { + console.warn('GPS info error:', error.message); + // Use cached GPS data from node-gpsd if available + if (gpsData.latitude !== null) { + return gpsData; + } + } + + return gpsInfo; +} + +async function getNetworkInfo() { + const networkInfo = { + wifi: { + mode: 'client', + ssid: 'Unknown' + }, + ethernet: { + connected: false + } + }; + + try { + // Check if hostapd is running (AP mode) + try { + await execAsync('systemctl is-active hostapd'); + networkInfo.wifi.mode = 'ap'; + + // Get AP SSID from hostapd config or use hostname + try { + const { stdout: hostname } = await execAsync('hostname'); + networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim(); + } catch { + networkInfo.wifi.ssid = 'MRS-AP'; + } + } catch { + // Not in AP mode, check if connected to WiFi + try { + // First, try iwconfig directly since it's more reliable for ESSID + const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null"); + const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/); + + if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') { + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = essidMatch[1]; + } else { + // Fallback to nmcli if iwconfig doesn't work + try { + const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1"); + if (activeWifi.trim()) { + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = activeWifi.trim(); + } else { + // Try getting SSID from currently connected interface + const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1"); + if (connectedSSID.trim()) { + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = connectedSSID.trim(); + } else { + throw new Error('No active WiFi connection found'); + } + } + } catch { + // Final fallback: check if wlan0 interface is up and get info via ip + const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l"); + if (parseInt(wlanStatus.trim()) > 0) { + // Interface has IP but we can't determine SSID + networkInfo.wifi.mode = 'client'; + networkInfo.wifi.ssid = 'Connected (Unknown SSID)'; + } else { + networkInfo.wifi.mode = 'disconnected'; + networkInfo.wifi.ssid = 'Not Connected'; + } + } + } + } catch (error) { + console.warn('WiFi detection error:', error.message); + networkInfo.wifi.mode = 'disconnected'; + networkInfo.wifi.ssid = 'Not Connected'; + } + } + + // Check Ethernet status + try { + const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'"); + networkInfo.ethernet.connected = ethStatus.includes('state UP'); + } catch { + networkInfo.ethernet.connected = false; + } + + } catch (error) { + console.warn('Network info error:', error.message); + } + + return networkInfo; +} + +let loraInfo = null; +async function getLoRaInfo() { + if(loraInfo) return loraInfo; + + loraInfo = { + connected: false, + onlineNodes: 0, + totalNodes: 0, + channelUtilization: 0, + airtimeUtilization: 0 + }; + + try { + console.log('Getting LoRa/Meshtastic status...'); + + // Get node information first + const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 }); + + if (nodeList && nodeList.trim()) { + loraInfo.connected = true; + + // Parse node list - look for actual node entries + const lines = nodeList.split('\n'); + let totalNodes = 0; + let onlineNodes = 0; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip header lines and separators + if (trimmedLine.includes('│') && + !trimmedLine.includes('User') && + !trimmedLine.includes('───') && + !trimmedLine.includes('Node') && + trimmedLine.length > 10) { + + totalNodes++; + + // Check if node is online (has recent lastHeard or SNR data) + if (trimmedLine.includes('SNR') || + trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s + trimmedLine.includes('now')) { + onlineNodes++; + } + } + } + + loraInfo.totalNodes = totalNodes; + loraInfo.onlineNodes = onlineNodes; + } + + // Get channel and airtime utilization from --info + try { + const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 }); + + if (meshInfo && meshInfo.trim()) { + const lines = meshInfo.split('\n'); + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Look for channel utilization (various formats) + if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) { + const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); + if (channelMatch) { + loraInfo.channelUtilization = parseFloat(channelMatch[1]); + } + } + + // Look for airtime utilization (various formats) + if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) { + const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); + if (airtimeMatch) { + loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]); + } + } + } + } + } catch (infoError) { + console.warn('Failed to get mesh info:', infoError.message); + } + + } catch (error) { + console.warn('LoRa info error:', error.message); + loraInfo.connected = false; + } + + setTimeout(() => loraInfo = null, 60000) + return loraInfo; +} + +// Main status generation function - matches frontend expectations +async function generateStatus() { + try { + console.log('Generating fresh status...'); + + const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([ + getSystemInfo(), + getGPSInfo(), + getNetworkInfo(), + getLoRaInfo() + ]); + + // Format exactly as the frontend expects + const status = { + system: { + temperature: systemInfo.temperature, + load: systemInfo.load, + cpu: systemInfo.cpu, + memory: systemInfo.memory, + storage: systemInfo.storage + }, + gps: { + fix: gpsInfo.fix, + satellites: gpsInfo.satellites, + latitude: gpsInfo.latitude, + longitude: gpsInfo.longitude, + altitude: gpsInfo.altitude + }, + network: { + wifi: networkInfo.wifi, + ethernet: networkInfo.ethernet + }, + lora: loraInfo, + time: new Date().toISOString() + }; + + return status; + } catch (error) { + console.error('Status generation error:', error); + throw error; + } +} + +router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname })); + +// Main status endpoint that matches frontend expectations +router.get('/status', async (req, res) => { + try { + const now = Date.now(); + + // Return cached status if within TTL + if (statusCache && (now - lastUpdate) < CACHE_TTL) { + console.log('Returning cached status'); + return res.json(statusCache); + } + + // Generate fresh status + statusCache = await generateStatus(); + lastUpdate = now; + + console.log('Status updated successfully'); + res.json(statusCache); + } catch (error) { + console.error('Status API error:', error); + res.status(500).json({ + error: 'Failed to get system status', + message: error.message, + timestamp: new Date().toISOString() + }); + } +}); + +// Individual debug endpoints +router.get('/system', async (req, res) => { + try { + const systemInfo = await getSystemInfo(); + res.json(systemInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/gps', async (req, res) => { + try { + const gpsInfo = await getGPSInfo(); + res.json(gpsInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/network', async (req, res) => { + try { + const networkInfo = await getNetworkInfo(); + res.json(networkInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Add individual LoRa endpoint +router.get('/lora', async (req, res) => { + try { + const loraInfo = await getLoRaInfo(); + res.json(loraInfo); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Initialize GPS on startup +initGPS().catch(err => console.warn('GPS initialization failed:', err.message)); + +export { router as statusRouter };