Compare commits

..

53 Commits

Author SHA1 Message Date
Will Greenberg a4c32f49ae also bump installer-guis version 2026-02-24 14:25:45 -08:00
Will Greenberg ec30a9557c bump to v10.2 2026-02-24 14:19:08 -08:00
Markus Unterwaditzer a7d38730f5 fix botched rebase 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer d9facdf6cb add one missing single quote 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer 90f49f73c8 slightly better escaping for user input 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer 8aa45f4b53 better errorhandling 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer d8da6118da Update installer/src/lib.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2026-02-24 13:42:31 -08:00
Markus Unterwaditzer 3e38f500a9 Install to /cache/rayhunter-data for tplink, add --data-dir parameter
This fixes several space-related issues at once.

We have observed the following phenomenon on TP-Link, Orbic and Moxee:

- Filling /data bricks the device (broken wifi, broken rndis, broken
  display)

- Filling /cache does not (it only bricks rayhunter if it's installed
  there, and it might break firmware updates)

Therefore it would make sense to store the entire rayhunter installation
in /cache.

This is a great idea for TP-Link and Moxee, because /cache is
significantly larger than /data. However, on Orbic, /data is
significantly larger than /cache!

This PR refactors orbic-network and tplink to use a shared codepath for
setting up the data directory. A symlink is created at /data/rayhunter,
and what it points to is device-specific:

- Orbic will have its data at `/data/rayhunter-data`

- There is a new alias `installer moxee` that overrides this to
  `/cache/rayhunter-data`

- TP-Link will have its data at /cache/rayhunter-data when there's no SD
  card, and /media/whatever when there is one.

In all cases, existing data is migrated to the new location. The user
can switch back and forth between two values of --data-dir and the data
will be moved over every time.

This PR has one huge wart, and that is that the USB installer for Orbic
remains untouched. The annoying reason for this is that the
DeviceConnection trait is insufficient to reflect all the different
kinds of shells you can have over USB: adb with fakeroot, and serial
with real root. I think it's not possible to create the right
directories with 'rootshell -c'.

I'm thinking of spawning a telnet server over serial, so that we can
just do telnet again, but this is for another time.
2026-02-24 13:42:31 -08:00
dependabot[bot] 83664e23f2 Bump @sveltejs/kit from 2.50.1 to 2.53.0 in /installer-gui
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.50.1 to 2.53.0.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.53.0/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.53.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 16:41:36 -08:00
dependabot[bot] 44c7f31fec Bump svelte from 5.43.2 to 5.53.0 in /installer-gui
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.43.2 to 5.53.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.53.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.53.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 16:41:10 -08:00
Markus Unterwaditzer 301107be6c add context to background nc 2026-02-23 11:18:47 -08:00
Markus Unterwaditzer 7b97ffc01d raise error for timeout 2026-02-23 11:18:47 -08:00
Markus Unterwaditzer b72712faa2 error case 2026-02-23 11:18:47 -08:00
Markus Unterwaditzer 05fdc0eee2 do not use debug_assert 2026-02-23 11:18:47 -08:00
Markus Unterwaditzer 8fb27b08f9 Fix telnet_send_command_with_output returning the input
telnet_send_command_with_output returns output with the original command
contained. This leads to higher-level bugs. Fix #894

Also, change telnet_send_command_with_output to not return any "exit
code" related output. This is now only part of telnet_send_command,
which means this output does not leak into users of the DeviceConnection
trait.
2026-02-23 11:18:47 -08:00
Matthew Martin 062db87572 Use Vec::with_capacity (#891)
Co-authored-by: Matthew Martin <phy1729@Matthews-Mac-mini.local>
2026-02-17 18:55:34 +01:00
Andrej Walilko 9b6c4cee0b api documentation (#876)
* api documentation

* utoipa openapi docs generator

---------

Co-authored-by: Andrej Walilko <awalilko@liquidweb.com>
2026-02-17 01:41:15 +01:00
Jake P 9d50db40b9 docs: add note about insecure download (#883)
* docs: add note about insecure download

* Apply suggestion from @untitaker

---------

Co-authored-by: Markus Unterwaditzer <markus-github@unterwaditzer.net>
2026-02-15 16:50:16 +01:00
Ember d41c4bba3e messages could be larger than 1MB when 100 messages are combined, changed to every 256KB space is checked. 2026-02-12 18:06:42 -08:00
Ember 1d5ed54033 deduplicated code a bit with a wrapper 2026-02-12 18:06:42 -08:00
Ember 24e79aad9d Handled suggestions from PR. 2026-02-12 18:06:42 -08:00
Ember bc7dcc97c6 Removed redundant annotations inlined the defaults 2026-02-12 18:06:42 -08:00
Ember 480b6f8681 Add visual for GUI; fix clippy issue. 2026-02-12 18:06:42 -08:00
Ember 0c624c2bc2 Add disk space monitoring to recording lifecycle 2026-02-12 18:06:42 -08:00
Ember ec6967e2a1 Revert silent IPC error drop, restore expect per review 2026-02-12 09:23:13 -08:00
Ember 912f7dfeaa Disable autocorrect/autocapitalize on CLI args input 2026-02-12 09:23:13 -08:00
Ember 51f1a33e86 Update Cargo.lock for shlex dependency 2026-02-12 09:23:13 -08:00
Ember 87c79bddf7 Input validation fix, along with changing expect so it won't crash 2026-02-12 09:23:13 -08:00
TERR-inss 5efa12f358 fix conditional rendering and conditional text logic, use more-stable faq url 2026-02-12 13:00:36 +01:00
TERR-inss e77fe469da add direct link to FAQ in web UI where rayhunter log analysis returns warnings 2026-02-12 13:00:36 +01:00
Markus Unterwaditzer ed8b1903f8 Re-add API_TARGET envvar 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 89d1d71ec9 Improve the default of FIRMWARE_DEVEL again, fix brew install gcc command 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 9be35de90e Address review feedback 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 8f9be746d3 Trim down documentation and script verbosity 2026-02-10 17:06:20 -08:00
BeigeBox 1347e3107a Support for admin pass 2026-02-10 17:06:20 -08:00
BeigeBox 715efc4b0d Basic scripts to build from source and run install. Nothing fancy. 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 836ec2169d Revamp installing-from-source docs 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 9128eefcfc advise against upgrading and add some dramatic styling to this 2026-02-10 17:06:00 -08:00
Markus Unterwaditzer 4f3c7fb7a9 Add warning to moxee page
see #865
2026-02-10 17:06:00 -08:00
BeigeBox 2d3824072d Added check if retcode was 201 when getting the login_response, and giving an error that says it's the pw 2026-02-08 15:08:14 +01:00
Cooper Quintin ed2781a4be appease clippy 2026-02-05 15:41:54 -08:00
Cooper Quintin ffcf683ae5 appease npm 2026-02-05 15:41:54 -08:00
Cooper Quintin 49fd777c83 fix nits and add to config.toml 2026-02-05 15:41:54 -08:00
Cooper Quintin 84a3155a1f remove broken attach request and format 2026-02-05 15:41:54 -08:00
Cooper Quintin 184f4bd7a2 rename to diagnostic and add docs 2026-02-05 15:41:54 -08:00
Cooper Quintin f7759721e3 rebase against main 2026-02-05 15:41:54 -08:00
Cooper Quintin 744d0772c2 add message type 2026-02-05 15:41:54 -08:00
Cooper Quintin 2cd49b3757 show false postive attach reject message 2026-02-05 15:41:54 -08:00
Cooper Quintin e44230c043 imsi revealing message diagnostic heuristic 2026-02-05 15:41:54 -08:00
Cooper Quintin e27da68b5d bump version to 0.10.1 2026-02-05 12:27:09 -08:00
Markus Unterwaditzer 2a68c99897 Revert "Add warning about default routes"
This reverts commit 9ae1563286.

Reverts #804
Reverts #830

Reopens #345
2026-02-05 12:21:07 -08:00
dependabot[bot] 987d95c23e Bump rsa from 0.9.8 to 0.9.10 (#853)
Bumps [rsa](https://github.com/RustCrypto/RSA) from 0.9.8 to 0.9.10.
- [Changelog](https://github.com/RustCrypto/RSA/blob/v0.9.10/CHANGELOG.md)
- [Commits](https://github.com/RustCrypto/RSA/compare/v0.9.8...v0.9.10)

---
updated-dependencies:
- dependency-name: rsa
  dependency-version: 0.9.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 21:06:41 +01:00
dependabot[bot] 9ef6b43dac Bump time from 0.3.41 to 0.3.47
Bumps [time](https://github.com/time-rs/time) from 0.3.41 to 0.3.47.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.41...v0.3.47)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.47
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-05 10:45:54 -08:00
68 changed files with 1661 additions and 413 deletions
+1
View File
@@ -7,3 +7,4 @@
dist/config.toml.in eol=lf dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf dist/scripts/rayhunter_daemon eol=lf
scripts/*.sh eol=lf
+61 -10
View File
@@ -24,7 +24,7 @@ jobs:
daemon_changed: ${{ steps.files_changed.outputs.daemon_count != '0' }} daemon_changed: ${{ steps.files_changed.outputs.daemon_count != '0' }}
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }} daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }} web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' }} docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }}
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }} installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }} installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }} rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
@@ -84,25 +84,25 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
persist-credentials: false persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: Install mdBook - name: Install mdBook
run: | run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook - name: Test mdBook
run: mdbook test run: mdbook test
mdbook_publish: mdbook_build:
name: Publish mdBook to Github Pages name: Build mdBook for Github Pages
needs: mdbook_test needs: mdbook_test
if: ${{ github.ref == 'refs/heads/main' }} if: ${{ github.ref == 'refs/heads/main' }}
permissions: permissions:
pages: write
contents: write contents: write
id-token: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
persist-credentials: false persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: Install mdBook - name: Install mdBook
run: | run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
@@ -110,14 +110,11 @@ jobs:
- name: Build mdBook - name: Build mdBook
run: mdbook build run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: book
path: book path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4
check_and_test: check_and_test:
needs: files_changed needs: files_changed
@@ -583,3 +580,57 @@ jobs:
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256 rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
if-no-files-found: error if-no-files-found: error
openapi_build:
if: needs.files_changed.outputs.docs_changed == 'true'
needs:
- files_changed
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-daemon openapi docs
run: |
mkdir -p daemon/web/build
touch daemon/web/build/{favicon.png,index.html.gz,rayhunter_orca_only.png,rayhunter_text.png}
cargo run --bin gen_api --features apidocs -- ./rayhunter-openapi.json
- name: Make swagger folder
run: |
mkdir api-docs
mv doc/swagger-ui.html api-docs/index.html
mv rayhunter-openapi.json api-docs/
- uses: actions/upload-artifact@v4
with:
name: api-docs
path: api-docs
github_pages_publish:
name: Upload new documentation to Github Pages
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
needs:
- mdbook_build
- openapi_build
runs-on: ubuntu-latest
steps:
- name: Setup Pages
uses: actions/configure-pages@v4
- uses: actions/download-artifact@v4
- name: Organize pages into directory
run: cp -a api-docs book/
- name: Upload pages
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy Github Pages
uses: actions/deploy-pages@v4
Generated
+47 -21
View File
@@ -1329,12 +1329,12 @@ dependencies = [
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.4.0" version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde", "serde_core",
] ]
[[package]] [[package]]
@@ -2725,7 +2725,7 @@ dependencies = [
[[package]] [[package]]
name = "installer" name = "installer"
version = "0.10.0" version = "0.10.2"
dependencies = [ dependencies = [
"adb_client", "adb_client",
"aes", "aes",
@@ -2753,12 +2753,13 @@ dependencies = [
[[package]] [[package]]
name = "installer-gui" name = "installer-gui"
version = "0.10.0" version = "0.10.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"installer", "installer",
"serde", "serde",
"serde_json", "serde_json",
"shlex",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
@@ -3430,9 +3431,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]] [[package]]
name = "num-derive" name = "num-derive"
@@ -4671,7 +4672,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]] [[package]]
name = "rayhunter" name = "rayhunter"
version = "0.10.0" version = "0.10.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
@@ -4690,11 +4691,12 @@ dependencies = [
"telcom-parser", "telcom-parser",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"utoipa",
] ]
[[package]] [[package]]
name = "rayhunter-check" name = "rayhunter-check"
version = "0.10.0" version = "0.10.2"
dependencies = [ dependencies = [
"clap", "clap",
"futures", "futures",
@@ -4707,7 +4709,7 @@ dependencies = [
[[package]] [[package]]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.10.0" version = "0.10.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -4731,6 +4733,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
"toml 0.8.22", "toml 0.8.22",
"utoipa",
] ]
[[package]] [[package]]
@@ -4895,16 +4898,16 @@ dependencies = [
[[package]] [[package]]
name = "rootshell" name = "rootshell"
version = "0.10.0" version = "0.10.2"
dependencies = [ dependencies = [
"nix 0.29.0", "nix 0.29.0",
] ]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.8" version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"digest", "digest",
@@ -5951,7 +5954,7 @@ dependencies = [
[[package]] [[package]]
name = "telcom-parser" name = "telcom-parser"
version = "0.10.0" version = "0.10.2"
dependencies = [ dependencies = [
"asn1-codecs", "asn1-codecs",
"asn1-compiler", "asn1-compiler",
@@ -6057,30 +6060,30 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.41" version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde", "serde_core",
"time-core", "time-core",
"time-macros", "time-macros",
] ]
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.4" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.22" version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@@ -6554,6 +6557,29 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
dependencies = [
"indexmap 2.12.1",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.18.1" version = "1.18.1"
+1
View File
@@ -6,3 +6,4 @@ title = "Rayhunter - An IMSI Catcher Catcher"
[output.html] [output.html]
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}" edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
additional-css = ["doc/custom.css"]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter-check" name = "rayhunter-check"
version = "0.10.0" version = "0.10.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+13 -2
View File
@@ -1,13 +1,23 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.10.0" version = "0.10.2"
edition = "2024" edition = "2024"
rust-version = "1.88.0" rust-version = "1.88.0"
[lib]
name = "rayhunter_daemon"
path = "src/lib.rs"
[[bin]]
name = "gen_api"
path = "src/bin/gen_api.rs"
required-features = ["apidocs"]
[features] [features]
default = ["rustcrypto-tls"] default = ["rustcrypto-tls"]
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"] rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
ring-tls = ["reqwest/rustls-tls-webpki-roots"] ring-tls = ["reqwest/rustls-tls-webpki-roots"]
apidocs = ["dep:utoipa"]
[dependencies] [dependencies]
rayhunter = { path = "../lib" } rayhunter = { path = "../lib" }
@@ -26,9 +36,10 @@ tokio-stream = { version = "0.1.14", default-features = false, features = ["io-u
futures = { version = "0.3.30", default-features = false } futures = { version = "0.3.30", default-features = false }
serde_json = "1.0.114" serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] } image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1" tempfile = "3.10.2"
async_zip = { version = "0.0.17", features = ["tokio"] } async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98" anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false } reqwest = { version = "0.12.20", default-features = false }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
async-trait = "0.1.88" async-trait = "0.1.88"
utoipa = { version = "5.4.0", optional = true }
+29
View File
@@ -77,10 +77,15 @@ impl AnalysisWriter {
} }
} }
/// The system status relating to QMDL file analysis
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct AnalysisStatus { pub struct AnalysisStatus {
/// The vector array of queued files
queued: Vec<String>, queued: Vec<String>,
/// The file currently being analyzed
running: Option<String>, running: Option<String>,
/// The vector array of finished files
finished: Vec<String>, finished: Vec<String>,
} }
@@ -215,6 +220,16 @@ pub fn run_analysis_thread(
}); });
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/analysis",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "Success", body = AnalysisStatus)
),
summary = "Analysis status",
description = "Show analysis status for all QMDL files."
))]
pub async fn get_analysis_status( pub async fn get_analysis_status(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> { ) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
@@ -231,6 +246,20 @@ fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>
true true
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/analysis/{name}",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Unable to queue analysis file")
),
params(
("name" = String, Path, description = "QMDL file to analyze")
),
summary = "Start analysis",
description = "Begin analysis of QMDL file {name}."
))]
pub async fn start_analysis( pub async fn start_analysis(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>, Path(qmdl_name): Path<String>,
+4
View File
@@ -18,9 +18,13 @@ pub mod wingtech;
const LOW_BATTERY_LEVEL: u8 = 10; const LOW_BATTERY_LEVEL: u8 = 10;
/// Device battery information
#[derive(Clone, Copy, PartialEq, Debug, Serialize)] #[derive(Clone, Copy, PartialEq, Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct BatteryState { pub struct BatteryState {
/// The current level in percentage of the device battery
level: u8, level: u8,
/// A boolean indicating whether the battery is currently being charged
is_plugged_in: bool, is_plugged_in: bool,
} }
+12
View File
@@ -0,0 +1,12 @@
use std::{env, fs};
fn main() {
let content = rayhunter_daemon::ApiDocs::generate();
let mut filename = "openapi.json".to_string();
let args: Vec<String> = env::args().collect();
if args.len() > 1 {
filename = args[1].to_string();
}
fs::write(filename, content).unwrap();
}
+16
View File
@@ -7,19 +7,33 @@ use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError; use crate::error::RayhunterError;
use crate::notifications::NotificationType; use crate::notifications::NotificationType;
/// The structure of a valid rayhunter configuration
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct Config { pub struct Config {
/// Path to store QMDL files
pub qmdl_store_path: String, pub qmdl_store_path: String,
/// Listening port
pub port: u16, pub port: u16,
/// Debug mode
pub debug_mode: bool, pub debug_mode: bool,
/// Internal device name
pub device: Device, pub device: Device,
/// UI level
pub ui_level: u8, pub ui_level: u8,
/// Colorblind mode
pub colorblind_mode: bool, pub colorblind_mode: bool,
/// Key input mode
pub key_input_mode: u8, pub key_input_mode: u8,
/// ntfy.sh URL
pub ntfy_url: Option<String>, pub ntfy_url: Option<String>,
/// Vector containing the types of enabled notifications
pub enabled_notifications: Vec<NotificationType>, pub enabled_notifications: Vec<NotificationType>,
/// Vector containing the list of enabled analyzers
pub analyzers: AnalyzerConfig, pub analyzers: AnalyzerConfig,
pub min_space_to_start_recording_mb: u64,
pub min_space_to_continue_recording_mb: u64,
} }
impl Default for Config { impl Default for Config {
@@ -35,6 +49,8 @@ impl Default for Config {
analyzers: AnalyzerConfig::default(), analyzers: AnalyzerConfig::default(),
ntfy_url: None, ntfy_url: None,
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery], enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
min_space_to_start_recording_mb: 1,
min_space_to_continue_recording_mb: 1,
} }
} }
} }
+250 -32
View File
@@ -17,6 +17,8 @@ use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream; use tokio_stream::wrappers::LinesStream;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
#[cfg(feature = "apidocs")]
use rayhunter::analysis::analyzer::ReportMetadata;
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType}; use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
@@ -27,10 +29,15 @@ use crate::display;
use crate::notifications::{Notification, NotificationType}; use crate::notifications::{Notification, NotificationType};
use crate::qmdl_store::{RecordingStore, RecordingStoreError}; use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState; use crate::server::ServerState;
use crate::stats::DiskStats;
const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
pub enum DiagDeviceCtrlMessage { pub enum DiagDeviceCtrlMessage {
StopRecording, StopRecording,
StartRecording, StartRecording {
response_tx: Option<oneshot::Sender<Result<(), String>>>,
},
DeleteEntry { DeleteEntry {
name: String, name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>, response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
@@ -46,8 +53,12 @@ pub struct DiagTask {
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
state: DiagState, state: DiagState,
max_type_seen: EventType, max_type_seen: EventType,
bytes_since_space_check: usize,
low_space_warned: bool,
} }
enum DiagState { enum DiagState {
@@ -58,36 +69,99 @@ enum DiagState {
Stopped, Stopped,
} }
enum DiskSpaceCheck {
Ok(u64),
Warning(u64),
Critical(u64),
Failed,
}
fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -> DiskSpaceCheck {
match DiskStats::new(path.to_str().unwrap()) {
Ok(stats) => {
let available_mb = stats.available_bytes.unwrap_or(0) / 1024 / 1024;
if available_mb < critical_mb {
DiskSpaceCheck::Critical(available_mb)
} else if available_mb < warning_mb {
DiskSpaceCheck::Warning(available_mb)
} else {
DiskSpaceCheck::Ok(available_mb)
}
}
Err(e) => {
warn!("Failed to check disk space: {e}");
DiskSpaceCheck::Failed
}
}
}
impl DiagTask { impl DiagTask {
fn new( fn new(
ui_update_sender: Sender<display::DisplayState>, ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) -> Self { ) -> Self {
Self { Self {
ui_update_sender, ui_update_sender,
analysis_sender, analysis_sender,
analyzer_config, analyzer_config,
notification_channel, notification_channel,
min_space_to_start_mb,
min_space_to_continue_mb,
state: DiagState::Stopped, state: DiagState::Stopped,
max_type_seen: EventType::Informational, max_type_seen: EventType::Informational,
bytes_since_space_check: 0,
low_space_warned: false,
} }
} }
/// Start recording /// Start recording, returning an error if disk space is too low.
async fn start(&mut self, qmdl_store: &mut RecordingStore) { async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
self.max_type_seen = EventType::Informational; self.max_type_seen = EventType::Informational;
let (qmdl_file, analysis_file) = qmdl_store self.bytes_since_space_check = 0;
.new_entry() self.low_space_warned = false;
.await
.expect("failed creating QMDL file entry"); match check_disk_space(
&qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
let msg = format!(
"Insufficient disk space: {}MB available, {}MB required",
mb, self.min_space_to_start_mb
);
error!("{msg}");
return Err(msg);
}
DiskSpaceCheck::Ok(mb) => {
info!("Starting recording with {}MB disk space available", mb);
}
DiskSpaceCheck::Failed => {}
}
let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await {
Ok(files) => files,
Err(e) => {
let msg = format!("failed creating QMDL file entry: {e}");
error!("{msg}");
return Err(msg);
}
};
self.stop_current_recording().await; self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file); let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config) let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
.await {
.map(Box::new) Ok(writer) => Box::new(writer),
.expect("failed to write to analysis file"); Err(e) => {
let msg = format!("failed to create analysis writer: {e}");
error!("{msg}");
return Err(msg);
}
};
self.state = DiagState::Recording { self.state = DiagState::Recording {
qmdl_writer, qmdl_writer,
analysis_writer, analysis_writer,
@@ -99,11 +173,17 @@ impl DiagTask {
{ {
warn!("couldn't send ui update message: {e}"); warn!("couldn't send ui update message: {e}");
} }
Ok(())
} }
/// Stop recording /// Stop recording, optionally annotating the entry with a reason.
async fn stop(&mut self, qmdl_store: &mut RecordingStore) { async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
self.stop_current_recording().await; self.stop_current_recording().await;
if let Some(reason) = reason
&& let Err(e) = qmdl_store.set_current_stop_reason(reason).await
{
warn!("couldn't set stop reason: {e}");
}
if let Some((_, entry)) = qmdl_store.get_current_entry() if let Some((_, entry)) = qmdl_store.get_current_entry()
&& let Err(e) = self && let Err(e) = self
.analysis_sender .analysis_sender
@@ -132,7 +212,7 @@ impl DiagTask {
name: &str, name: &str,
) -> Result<(), RecordingStoreError> { ) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) { if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store).await; self.stop(qmdl_store, None).await;
} }
let res = qmdl_store.delete_entry(name).await; let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() { if let Err(e) = res.as_ref() {
@@ -145,7 +225,7 @@ impl DiagTask {
&mut self, &mut self,
qmdl_store: &mut RecordingStore, qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> { ) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store).await; self.stop(qmdl_store, None).await;
let res = qmdl_store.delete_all_entries().await; let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() { if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}"); error!("Error deleting QMDL entries {e}");
@@ -183,10 +263,56 @@ impl DiagTask {
analysis_writer, analysis_writer,
} = &mut self.state } = &mut self.state
{ {
qmdl_writer if self.bytes_since_space_check >= DISK_CHECK_BYTES_INTERVAL {
.write_container(&container) self.bytes_since_space_check = 0;
.await match check_disk_space(
.expect("failed to write to QMDL writer"); &qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) => {
let reason = format!(
"Disk space critically low ({}MB free), recording stopped automatically",
mb
);
error!("{reason}");
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
reason.clone(),
None,
))
.await
.ok();
self.stop(qmdl_store, Some(reason)).await;
return;
}
DiskSpaceCheck::Warning(mb) => {
if !self.low_space_warned {
self.low_space_warned = true;
warn!("Disk space low: {}MB remaining", mb);
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Disk space low: {}MB free", mb),
Some(Duration::from_secs(30)),
))
.await
.ok();
}
}
_ => {}
}
}
if let Err(e) = qmdl_writer.write_container(&container).await {
let reason = format!("failed to write to QMDL (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!( debug!(
"total QMDL bytes written: {}, updating manifest...", "total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written qmdl_writer.total_written
@@ -194,15 +320,25 @@ impl DiagTask {
let index = qmdl_store let index = qmdl_store
.current_entry .current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???"); .expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store if let Err(e) = qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written) .update_entry_qmdl_size(index, qmdl_writer.total_written)
.await .await
.expect("failed to update qmdl file size"); {
let reason = format!("failed to update manifest (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!("done!"); debug!("done!");
let max_type = analysis_writer let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
.analyze(container) self.bytes_since_space_check += container_bytes;
.await let max_type = match analysis_writer.analyze(container).await {
.expect("failed to analyze container"); Ok(t) => t,
Err(e) => {
warn!("failed to analyze container: {e}");
EventType::Informational
}
};
if max_type > EventType::Informational { if max_type > EventType::Informational {
info!("a heuristic triggered on this run!"); info!("a heuristic triggered on this run!");
@@ -244,25 +380,30 @@ pub fn run_diag_read_thread(
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) { ) {
task_tracker.spawn(async move { task_tracker.spawn(async move {
let mut diag_stream = pin!(dev.as_stream().into_stream()); let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel); let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel, min_space_to_start_mb, min_space_to_continue_mb);
qmdl_file_tx qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording) .send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await .await
.unwrap(); .unwrap();
loop { loop {
tokio::select! { tokio::select! {
msg = qmdl_file_rx.recv() => { msg = qmdl_file_rx.recv() => {
match msg { match msg {
Some(DiagDeviceCtrlMessage::StartRecording) => { Some(DiagDeviceCtrlMessage::StartRecording { response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.start(qmdl_store.deref_mut()).await; let result = diag_task.start(qmdl_store.deref_mut()).await;
if let Some(tx) = response_tx {
tx.send(result).ok();
}
}, },
Some(DiagDeviceCtrlMessage::StopRecording) => { Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.stop(qmdl_store.deref_mut()).await; diag_task.stop(qmdl_store.deref_mut(), None).await;
}, },
// None means all the Senders have been dropped, so it's // None means all the Senders have been dropped, so it's
// time to go // time to go
@@ -305,6 +446,18 @@ pub fn run_diag_read_thread(
} }
/// Start recording API for web thread /// Start recording API for web thread
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/start-recording",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
),
summary = "Start recording",
description = "Begin a new data capture."
))]
pub async fn start_recording( pub async fn start_recording(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -312,9 +465,12 @@ pub async fn start_recording(
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
let (response_tx, response_rx) = oneshot::channel();
state state
.diag_device_ctrl_sender .diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StartRecording) .send(DiagDeviceCtrlMessage::StartRecording {
response_tx: Some(response_tx),
})
.await .await
.map_err(|e| { .map_err(|e| {
( (
@@ -323,10 +479,29 @@ pub async fn start_recording(
) )
})?; })?;
Ok((StatusCode::ACCEPTED, "ok".to_string())) match response_rx.await {
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive start recording response: {e}"),
)),
}
} }
/// Stop recording API for web thread /// Stop recording API for web thread
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/stop-recording",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
),
summary = "Stop recording",
description = "Stop current data capture."
))]
pub async fn stop_recording( pub async fn stop_recording(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -346,6 +521,22 @@ pub async fn stop_recording(
Ok((StatusCode::ACCEPTED, "ok".to_string())) Ok((StatusCode::ACCEPTED, "ok".to_string()))
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/delete-recording/{name}",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful"),
(status = StatusCode::BAD_REQUEST, description = "Bad recording name or no such recording")
),
params(
("name" = String, Path, description = "QMDL file to delete")
),
summary = "Delete recording",
description = "Remove data capture file named {name}."
))]
pub async fn delete_recording( pub async fn delete_recording(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>, Path(qmdl_name): Path<String>,
@@ -385,6 +576,18 @@ pub async fn delete_recording(
} }
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/delete-all-recordings",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful")
),
summary = "Delete all recordings",
description = "Remove all saved data capture files."
))]
pub async fn delete_all_recordings( pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -416,6 +619,21 @@ pub async fn delete_all_recordings(
} }
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/analysis-report/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "Success", body = ReportMetadata, content_type = "application/x-ndjson"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "No QMDL files available; start a new recording."),
(status = StatusCode::NOT_FOUND, description = "File {name} not found")
),
params(
("name" = String, Path, description = "QMDL file to analyze")
),
summary = "Analysis report",
description = "Download processed analysis report for QMDL file {name}, as well as the types (and versions) of analyzers used."
))]
pub async fn get_analysis_report( pub async fn get_analysis_report(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>, Path(qmdl_name): Path<String>,
+2 -2
View File
@@ -102,7 +102,7 @@ pub trait GenericFramebuffer: Send + 'static {
resized_img = img; resized_img = img;
} }
let img_rgba8 = resized_img.as_rgba8().unwrap(); let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::new(); let mut buf = Vec::with_capacity((height * width).try_into().unwrap());
for y in 0..height { for y in 0..height {
for x in 0..width { for x in 0..width {
let px = img_rgba8.get_pixel(x, y); let px = img_rgba8.get_pixel(x, y);
@@ -145,7 +145,7 @@ pub trait GenericFramebuffer: Send + 'static {
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) { async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
let width = self.dimensions().width; let width = self.dimensions().width;
let mut buffer = Vec::new(); let mut buffer = Vec::with_capacity((height * width).try_into().unwrap());
for _row in 0..height { for _row in 0..height {
for col in 0..width { for col in 0..width {
+2
View File
@@ -12,7 +12,9 @@ pub mod tplink_onebit;
pub mod uz801; pub mod uz801;
pub mod wingtech; pub mod wingtech;
/// A list of available display states
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum DisplayState { pub enum DisplayState {
/// We're recording but no warning has been found yet. /// We're recording but no warning has been found yet.
Recording, Recording,
+1 -1
View File
@@ -23,7 +23,7 @@ impl GenericFramebuffer for Framebuffer {
} }
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) { async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::new(); let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer { for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8; let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3; rgb565 |= (g as u16 & 0b11111100) << 3;
+1 -1
View File
@@ -50,7 +50,7 @@ impl GenericFramebuffer for Framebuffer {
rop: 0, rop: 0,
}; };
let mut raw_buffer = Vec::new(); let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer { for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8; let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3; rgb565 |= (g as u16 & 0b11111100) << 3;
+1 -1
View File
@@ -28,7 +28,7 @@ impl GenericFramebuffer for Framebuffer {
} }
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) { async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::new(); let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer { for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8; let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3; rgb565 |= (g as u16 & 0b11111100) << 3;
+3 -2
View File
@@ -81,8 +81,9 @@ pub fn run_key_input_thread(
{ {
error!("Failed to send StopRecording: {e}"); error!("Failed to send StopRecording: {e}");
} }
if let Err(e) = if let Err(e) = diag_tx
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await .send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await
{ {
error!("Failed to send StartRecording: {e}"); error!("Failed to send StartRecording: {e}");
} }
+71
View File
@@ -0,0 +1,71 @@
pub mod analysis;
pub mod battery;
pub mod config;
pub mod diag;
pub mod display;
pub mod error;
pub mod key_input;
pub mod notifications;
pub mod pcap;
pub mod qmdl_store;
pub mod server;
pub mod stats;
#[cfg(feature = "apidocs")]
use utoipa::OpenApi;
// Add anotated paths to api docs
#[cfg(feature = "apidocs")]
#[derive(OpenApi)]
#[openapi(
info(
description = "OpenAPI documentation for Rayhunter daemon\n\n**Note:** API endpoints are subject to change as needs arise, though we will try to keep them as stable as possible and notify about breaking changes in the changelogs for new versions.\n\nNo endpoints require any authentication. To use the in-browser execution on this page, you may need to disable CORS temporarily for your browser.",
license(
name = "GNU General Public License v3.0",
url = "https://github.com/EFForg/rayhunter/blob/main/LICENSE"
)
),
paths(
pcap::get_pcap,
server::get_qmdl,
server::get_zip,
stats::get_system_stats,
stats::get_qmdl_manifest,
stats::get_log,
diag::start_recording,
diag::stop_recording,
diag::delete_recording,
diag::delete_all_recordings,
diag::get_analysis_report,
analysis::get_analysis_status,
analysis::start_analysis,
server::get_config,
server::set_config,
server::test_notification,
server::get_time,
server::set_time_offset,
server::debug_set_display_state
),
servers(
(
url = "http://localhost:8080",
description = "ADB port bridge"
),
(
url = "http://192.168.1.1:8080",
description = "Orbic WiFi GUI"
),
(
url = "http://192.168.0.1:8080",
description = "TPLink WiFi GUI"
),
)
)]
pub struct ApiDocs;
#[cfg(feature = "apidocs")]
impl ApiDocs {
pub fn generate() -> String {
ApiDocs::openapi().to_pretty_json().unwrap()
}
}
+3 -2
View File
@@ -25,7 +25,7 @@ use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static, ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
set_config, set_time_offset, test_notification, set_config, set_time_offset, test_notification,
}; };
use crate::stats::{get_qmdl_manifest, get_route_status, get_system_stats}; use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{ use analysis::{
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
@@ -58,7 +58,6 @@ fn get_router() -> AppRouter {
.route("/api/qmdl/{name}", get(get_qmdl)) .route("/api/qmdl/{name}", get(get_qmdl))
.route("/api/zip/{name}", get(get_zip)) .route("/api/zip/{name}", get(get_zip))
.route("/api/system-stats", get(get_system_stats)) .route("/api/system-stats", get(get_system_stats))
.route("/api/route-status", get(get_route_status))
.route("/api/qmdl-manifest", get(get_qmdl_manifest)) .route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/log", get(get_log)) .route("/api/log", get(get_log))
.route("/api/start-recording", post(start_recording)) .route("/api/start-recording", post(start_recording))
@@ -235,6 +234,8 @@ async fn run_with_config(
analysis_tx.clone(), analysis_tx.clone(),
config.analyzers.clone(), config.analyzers.clone(),
notification_service.new_handler(), notification_service.new_handler(),
config.min_space_to_start_recording_mb,
config.min_space_to_continue_recording_mb,
); );
info!("Starting UI"); info!("Starting UI");
+2
View File
@@ -18,7 +18,9 @@ pub enum NotificationError {
HttpError(reqwest::StatusCode), HttpError(reqwest::StatusCode),
} }
/// Enum of valid notification types
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)] #[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum NotificationType { pub enum NotificationType {
Warning, Warning,
LowBattery, LowBattery,
+16 -1
View File
@@ -1,4 +1,4 @@
use crate::ServerState; use crate::server::ServerState;
use anyhow::Error; use anyhow::Error;
use axum::body::Body; use axum::body::Body;
@@ -18,6 +18,21 @@ use tokio_util::io::ReaderStream;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data // Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
// written so far. This is done by spawning a thread which streams chunks of // written so far. This is done by spawning a thread which streams chunks of
// pcap data to a channel that's piped to the client. // pcap data to a channel that's piped to the client.
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/pcap/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "PCAP conversion successful", content_type = "application/vnd.tcpdump.pcap"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a PCAP file",
description = "Stream a PCAP file to a client in chunks by converting the QMDL data for file {name} written so far."
))]
pub async fn get_pcap( pub async fn get_pcap(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(mut qmdl_name): Path<String>, Path(mut qmdl_name): Path<String>,
+26
View File
@@ -45,15 +45,28 @@ pub struct Manifest {
pub entries: Vec<ManifestEntry>, pub entries: Vec<ManifestEntry>,
} }
/// The structure of an entry in the QMDL manifest table
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)] #[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct ManifestEntry { pub struct ManifestEntry {
/// The name of the entry
pub name: String, pub name: String,
/// The system time when recording began
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub start_time: DateTime<Local>, pub start_time: DateTime<Local>,
/// The system time when the last message was recorded to the file
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub last_message_time: Option<DateTime<Local>>, pub last_message_time: Option<DateTime<Local>>,
/// The size of the QMDL file in bytes
pub qmdl_size_bytes: usize, pub qmdl_size_bytes: usize,
/// The rayhunter daemon version which generated the file
pub rayhunter_version: Option<String>, pub rayhunter_version: Option<String>,
/// The OS which created the file
pub system_os: Option<String>, pub system_os: Option<String>,
/// The architecture on which the OS was running
pub arch: Option<String>, pub arch: Option<String>,
#[serde(default)]
pub stop_reason: Option<String>,
} }
impl ManifestEntry { impl ManifestEntry {
@@ -68,6 +81,7 @@ impl ManifestEntry {
rayhunter_version: Some(metadata.rayhunter_version), rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os), system_os: Some(metadata.system_os),
arch: Some(metadata.arch), arch: Some(metadata.arch),
stop_reason: None,
} }
} }
@@ -197,6 +211,7 @@ impl RecordingStore {
rayhunter_version: None, rayhunter_version: None,
system_os: None, system_os: None,
arch: None, arch: None,
stop_reason: None,
}); });
} }
@@ -342,6 +357,17 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index])) Some((entry_index, &self.manifest.entries[entry_index]))
} }
pub async fn set_current_stop_reason(
&mut self,
reason: String,
) -> Result<(), RecordingStoreError> {
if let Some(idx) = self.current_entry {
self.manifest.entries[idx].stop_reason = Some(reason);
self.write_manifest().await?;
}
Ok(())
}
pub fn is_current_entry(&self, name: &str) -> bool { pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry { match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) { Some(idx) => match self.manifest.entries.get(idx) {
+111 -1
View File
@@ -21,9 +21,9 @@ use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::DiagDeviceCtrlMessage;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config; use crate::config::Config;
use crate::diag::DiagDeviceCtrlMessage;
use crate::display::DisplayState; use crate::display::DisplayState;
use crate::pcap::generate_pcap_data; use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
@@ -39,6 +39,21 @@ pub struct ServerState {
pub ui_update_sender: Option<Sender<DisplayState>>, pub ui_update_sender: Option<Sender<DisplayState>>,
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/qmdl/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "QMDL download successful", content_type = "application/octet-stream"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a QMDL file",
description = "Stream the QMDL file {name} to the client."
))]
pub async fn get_qmdl( pub async fn get_qmdl(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>, Path(qmdl_name): Path<String>,
@@ -106,12 +121,38 @@ pub async fn serve_static(
} }
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/config",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = Config)
),
summary = "Get config",
description = "Show the running configuration for Rayhunter."
))]
pub async fn get_config( pub async fn get_config(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<Json<Config>, (StatusCode, String)> { ) -> Result<Json<Config>, (StatusCode, String)> {
Ok(Json(state.config.clone())) Ok(Json(state.config.clone()))
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/config",
tag = "Configuration",
request_body(
content = Option<[Config]>,
description = "Any or all configuration elements from the valid config schema to be altered may be passed. Invalid keys will be discarded. Invalid values or value types will return an error."
),
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to parse or write config file"),
(status = 422, description = "Failed to deserialize JSON body")
),
summary = "Set config",
description = "Write a new configuration for Rayhunter and trigger a restart."
))]
pub async fn set_config( pub async fn set_config(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Json(config): Json<Config>, Json(config): Json<Config>,
@@ -138,6 +179,18 @@ pub async fn set_config(
)) ))
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/test-notification",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success"),
(status = StatusCode::BAD_REQUEST, description = "No notification URL set"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send HTTP request. Ensure your device can reach the internet.")
),
summary = "Test ntfy notification",
description = "Send a test notification to the ntfy_url in the running configuration for Rayhunter."
))]
pub async fn test_notification( pub async fn test_notification(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -174,10 +227,13 @@ pub async fn test_notification(
/// Response for GET /api/time /// Response for GET /api/time
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct TimeResponse { pub struct TimeResponse {
/// The raw system time (without clock offset) /// The raw system time (without clock offset)
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub system_time: DateTime<Local>, pub system_time: DateTime<Local>,
/// The adjusted time (system time + offset) /// The adjusted time (system time + offset)
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub adjusted_time: DateTime<Local>, pub adjusted_time: DateTime<Local>,
/// The current offset in seconds /// The current offset in seconds
pub offset_seconds: i64, pub offset_seconds: i64,
@@ -185,11 +241,22 @@ pub struct TimeResponse {
/// Request for POST /api/time-offset /// Request for POST /api/time-offset
#[derive(Deserialize)] #[derive(Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct SetTimeOffsetRequest { pub struct SetTimeOffsetRequest {
/// The offset to set, in seconds /// The offset to set, in seconds
pub offset_seconds: i64, pub offset_seconds: i64,
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/time",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = TimeResponse)
),
summary = "Get time",
description = "Get the current time and offset (in seconds) of the device."
))]
pub async fn get_time() -> Json<TimeResponse> { pub async fn get_time() -> Json<TimeResponse> {
let system_time = Local::now(); let system_time = Local::now();
let adjusted_time = rayhunter::clock::get_adjusted_now(); let adjusted_time = rayhunter::clock::get_adjusted_now();
@@ -203,11 +270,39 @@ pub async fn get_time() -> Json<TimeResponse> {
}) })
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/time-offset",
tag = "Configuration",
request_body(
content = SetTimeOffsetRequest
),
responses(
(status = StatusCode::OK, description = "Success", body = TimeResponse)
),
summary = "Set time offset",
description = "Set the difference (in seconds) between the system time and the adjusted time for Rayhunter."
))]
pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode { pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode {
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds)); rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
StatusCode::OK StatusCode::OK
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/zip/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "ZIP download successful. It is possible that if the PCAP fails to convert, the same status will be returned, but the file will contain only the QMDL file.", content_type = "application/zip"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a ZIP file",
description = "Stream a ZIP file to the client which contains the QMDL file {name} and a PCAP generated from the same file."
))]
pub async fn get_zip( pub async fn get_zip(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(entry_name): Path<String>, Path(entry_name): Path<String>,
@@ -299,6 +394,21 @@ pub async fn get_zip(
Ok((headers, body).into_response()) Ok((headers, body).into_response())
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/debug/display-state",
tag = "Configuration",
request_body(
content = DisplayState
),
responses(
(status = StatusCode::OK, description = "Display state updated successfully"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error sending update to the display"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "Display system not available")
),
summary = "Set display state",
description = "Change the display state (color bar or otherwise) of the device for debugging purposes."
))]
pub async fn debug_set_display_state( pub async fn debug_set_display_state(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Json(display_state): Json<DisplayState>, Json(display_state): Json<DisplayState>,
+83 -52
View File
@@ -1,3 +1,4 @@
use std::ffi::CString;
use std::sync::Arc; use std::sync::Arc;
use crate::battery::get_battery_status; use crate::battery::get_battery_status;
@@ -13,7 +14,9 @@ use rayhunter::{Device, util::RuntimeMetadata};
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
/// Structure of device system statistics
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct SystemStats { pub struct SystemStats {
pub disk_stats: DiskStats, pub disk_stats: DiskStats,
pub memory_stats: MemoryStats, pub memory_stats: MemoryStats,
@@ -25,7 +28,7 @@ pub struct SystemStats {
impl SystemStats { impl SystemStats {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> { pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
Ok(Self { Ok(Self {
disk_stats: DiskStats::new(qmdl_path, device).await?, disk_stats: DiskStats::new(qmdl_path)?,
memory_stats: MemoryStats::new(device).await?, memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(), runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await { battery_status: match get_battery_status(device).await {
@@ -40,49 +43,71 @@ impl SystemStats {
} }
} }
/// Device storage information
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct DiskStats { pub struct DiskStats {
/// The partition to which the daemon is installed
partition: String, partition: String,
/// The total disk size of the partition
total_size: String, total_size: String,
/// Total used size of the partition
used_size: String, used_size: String,
/// Remaining free space of the partition
available_size: String, available_size: String,
/// Disk usage displayed as percentage
used_percent: String, used_percent: String,
/// The root folder to which the partition is mounted
mounted_on: String, mounted_on: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub available_bytes: Option<u64>,
} }
impl DiskStats { impl DiskStats {
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing #[allow(clippy::unnecessary_cast)] // c_ulong is u32 on ARM, u64 on macOS
// the QMDL file. pub fn new(qmdl_path: &str) -> Result<Self, String> {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> { let c_path =
// Uz801 needs to be told to use the busybox df specifically CString::new(qmdl_path).map_err(|e| format!("invalid path {qmdl_path}: {e}"))?;
let mut df_cmd: Command; let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
if matches!(device, Device::Uz801) { if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
df_cmd = Command::new("busybox"); return Err(format!(
df_cmd.arg("df"); "statvfs({qmdl_path}) failed: {}",
} else { std::io::Error::last_os_error()
df_cmd = Command::new("df"); ));
} }
df_cmd.arg("-h");
df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?;
// Handle standard df -h format let block_size = stat.f_frsize as u64;
let mut parts = stdout.split_whitespace().skip(7); let total_kb = (stat.f_blocks as u64 * block_size / 1024) as usize;
let free_kb = (stat.f_bfree as u64 * block_size / 1024) as usize;
let available_kb = (stat.f_bavail as u64 * block_size / 1024) as usize;
let used_kb = total_kb.saturating_sub(free_kb);
let used_percent = if stat.f_blocks > 0 {
format!("{}%", (stat.f_blocks - stat.f_bfree) * 100 / stat.f_blocks)
} else {
"0%".to_string()
};
Ok(Self { Ok(Self {
partition: parts.next().ok_or("error parsing df output")?.to_string(), partition: qmdl_path.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(), total_size: humanize_kb(total_kb),
used_size: parts.next().ok_or("error parsing df output")?.to_string(), used_size: humanize_kb(used_kb),
available_size: parts.next().ok_or("error parsing df output")?.to_string(), available_size: humanize_kb(available_kb),
used_percent: parts.next().ok_or("error parsing df output")?.to_string(), used_percent,
mounted_on: parts.next().ok_or("error parsing df output")?.to_string(), mounted_on: qmdl_path.to_string(),
available_bytes: Some(stat.f_bavail as u64 * block_size),
}) })
} }
} }
/// Device memory information
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct MemoryStats { pub struct MemoryStats {
/// The total memory available on the device
total: String, total: String,
/// The currently used memory
used: String, used: String,
/// Remaining free memory
free: String, free: String,
} }
@@ -135,6 +160,17 @@ fn humanize_kb(kb: usize) -> String {
format!("{:.1}M", kb as f64 / 1024.0) format!("{:.1}M", kb as f64 / 1024.0)
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/system-stats",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", body = SystemStats),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error collecting statistics")
),
summary = "Get system info",
description = "Display system/device statistics."
))]
pub async fn get_system_stats( pub async fn get_system_stats(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<Json<SystemStats>, (StatusCode, String)> { ) -> Result<Json<SystemStats>, (StatusCode, String)> {
@@ -151,12 +187,26 @@ pub async fn get_system_stats(
} }
} }
/// QMDL manifest information
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct ManifestStats { pub struct ManifestStats {
/// A vector containing the names of the QMDL files
pub entries: Vec<ManifestEntry>, pub entries: Vec<ManifestEntry>,
/// The currently open QMDL file
pub current_entry: Option<ManifestEntry>, pub current_entry: Option<ManifestEntry>,
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/qmdl-manifest",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", body = ManifestStats)
),
summary = "QMDL Manifest",
description = "List QMDL files available on the device and some of their basic statistics."
))]
pub async fn get_qmdl_manifest( pub async fn get_qmdl_manifest(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<Json<ManifestStats>, (StatusCode, String)> { ) -> Result<Json<ManifestStats>, (StatusCode, String)> {
@@ -169,38 +219,19 @@ pub async fn get_qmdl_manifest(
})) }))
} }
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/log",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", content_type = "text/plain"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Could not read /data/rayhunter/rayhunter.log file")
),
summary = "Display log",
description = "Download the current device log in UTF-8 plaintext."
))]
pub async fn get_log() -> Result<String, (StatusCode, String)> { pub async fn get_log() -> Result<String, (StatusCode, String)> {
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log") tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
} }
#[derive(Debug, Serialize)]
pub struct RouteStatus {
pub has_default_route: bool,
}
pub async fn get_route_status() -> Json<RouteStatus> {
let has_default_route = match check_default_route().await {
Ok(result) => result,
Err(err) => {
log::warn!("Failed to check default route: {err}");
// More likely than not, this is a portability issue. The logic hasn't been fully
// tested on all devices. We shouldn't scare users unnecessarily.
true
}
};
Json(RouteStatus { has_default_route })
}
// Checks for an IPv4 default route by reading /proc/net/route
// instead of shelling out to `ip route`, which may not be available on all devices.
async fn check_default_route() -> std::io::Result<bool> {
let contents = tokio::fs::read_to_string("/proc/net/route").await?;
Ok(contents.lines().skip(1).any(|line| {
line.split_whitespace()
.nth(1)
.is_some_and(|dest| dest == "00000000")
}))
}
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { action_errors } from '../action_errors.svelte'; import { action_errors } from '../action_errors.svelte';
import WarningIcon from './WarningIcon.svelte';
let pos = $state(0); let pos = $state(0);
let current_error = $derived(action_errors[pos]); let current_error = $derived(action_errors[pos]);
@@ -26,7 +25,21 @@
> >
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600"> <span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
<WarningIcon class="w-6 h-6 text-red-600" /> <svg
class="w-6 h-6 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''} Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
</span> </span>
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
@@ -22,10 +22,26 @@
<p>Error getting analysis report: {entry.analysis_report}</p> <p>Error getting analysis report: {entry.analysis_report}</p>
{:else} {:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata} {@const metadata: ReportMetadata = entry.analysis_report.metadata}
{@const numWarnings: number = entry.get_num_warnings() || 0}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if !current} {#if !!numWarnings || !current}
<div class="flex flex-row justify-end items-center"> <div class="flex flex-row justify-between items-center">
<ReAnalyzeButton {entry} {manager} /> {#if !!numWarnings}
<div
class="text-red-700 border-red-500 border rounded-lg text-blue-600 px-2 py-1 mr-12"
>
Your Rayhunter device raised {`${numWarnings}`} warning{`${
numWarnings > 1 ? 's' : ''
}`}!
<a
href="https://efforg.github.io/rayhunter/faq.html#red"
class="text-blue-600 underline">Read the FAQ</a
> to learn what you can do about it
</div>
{/if}
{#if !current}
<ReAnalyzeButton {entry} {manager} />
{/if}
</div> </div>
{/if} {/if}
{#if entry.analysis_report.rows.length > 0} {#if entry.analysis_report.rows.length > 0}
@@ -241,6 +241,48 @@
</div> </div>
</div> </div>
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
<div>
<label
for="min_space_to_start_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Start Recording (MB)
</label>
<input
id="min_space_to_start_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_start_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will not start if less than this amount of disk space is free
</p>
</div>
<div>
<label
for="min_space_to_continue_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Continue Recording (MB)
</label>
<input
id="min_space_to_continue_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_continue_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will stop automatically if disk space drops below this level
</p>
</div>
</div>
<div class="border-t pt-4 mt-6"> <div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4"> <h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings Analyzer Heuristic Settings
@@ -335,6 +377,20 @@
Test Heuristic (noisy!) Test Heuristic (noisy!)
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="diagnostic_analyzer"
type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="diagnostic_analyzer"
class="ml-2 block text-sm text-gray-700"
>
Diagnostic Analyzer
</label>
</div>
</div> </div>
</div> </div>
@@ -1,52 +0,0 @@
<script lang="ts">
import { get_route_status } from '$lib/utils.svelte';
import WarningIcon from './WarningIcon.svelte';
let show_alert = $state(false);
let check_completed = $state(false);
async function check_route() {
if (check_completed) return;
try {
const status = await get_route_status();
if (!status.has_default_route) {
show_alert = true;
}
} catch (err) {
console.error('Failed to check route status:', err);
}
check_completed = true;
}
function dismiss() {
show_alert = false;
}
$effect(() => {
check_route();
});
</script>
{#if show_alert}
<div
class="bg-yellow-100 border-yellow-400 drop-shadow p-4 flex flex-col gap-2 border rounded-md"
>
<span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
<WarningIcon class="w-6 h-6 text-yellow-600" />
No Default Route Detected
</span>
<p>
This device didn't get an IP address from the network operator. Presumably the SIM card
is not inserted or very old. Try a different SIM card.
</p>
<div class="flex flex-row gap-2 justify-end">
<button
class="font-medium py-2 px-4 rounded-md border border-gray-400 hover:bg-yellow-200"
onclick={dismiss}
>
Dismiss
</button>
</div>
</div>
{/if}
@@ -81,6 +81,11 @@
'N/A'}</span 'N/A'}</span
> >
</div> </div>
{#if entry.stop_reason}
<div class="bg-yellow-50 border border-yellow-300 rounded p-2 text-yellow-800 text-sm">
{entry.stop_reason}
</div>
{/if}
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto"> <div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button /> <DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button /> <DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
@@ -1,19 +0,0 @@
<script lang="ts">
let { class: className = 'w-6 h-6' }: { class?: string } = $props();
</script>
<svg
class={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
+5
View File
@@ -11,6 +11,7 @@ interface JsonManifestEntry {
start_time: string; start_time: string;
last_message_time: string; last_message_time: string;
qmdl_size_bytes: number; qmdl_size_bytes: number;
stop_reason: string | null;
} }
export class Manifest { export class Manifest {
@@ -57,6 +58,7 @@ export class ManifestEntry {
public analysis_size_bytes = $state(0); public analysis_size_bytes = $state(0);
public analysis_status: AnalysisStatus | undefined = $state(undefined); public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined); public analysis_report: AnalysisReport | string | undefined = $state(undefined);
public stop_reason: string | undefined = $state(undefined);
constructor(json: JsonManifestEntry) { constructor(json: JsonManifestEntry) {
this.name = json.name; this.name = json.name;
@@ -65,6 +67,9 @@ export class ManifestEntry {
if (json.last_message_time) { if (json.last_message_time) {
this.last_message_time = new Date(json.last_message_time); this.last_message_time = new Date(json.last_message_time);
} }
if (json.stop_reason) {
this.stop_reason = json.stop_reason;
}
} }
get_readable_qmdl_size(): string { get_readable_qmdl_size(): string {
+1
View File
@@ -18,6 +18,7 @@ export interface DiskStats {
available_size: string; available_size: string;
used_percent: string; used_percent: string;
mounted_on: string; mounted_on: string;
available_bytes?: number;
} }
export interface MemoryStats { export interface MemoryStats {
+3 -8
View File
@@ -10,6 +10,7 @@ export interface AnalyzerConfig {
nas_null_cipher: boolean; nas_null_cipher: boolean;
incomplete_sib: boolean; incomplete_sib: boolean;
test_analyzer: boolean; test_analyzer: boolean;
diagnostic_analyzer: boolean;
} }
export enum enabled_notifications { export enum enabled_notifications {
@@ -24,6 +25,8 @@ export interface Config {
ntfy_url: string; ntfy_url: string;
enabled_notifications: enabled_notifications[]; enabled_notifications: enabled_notifications[];
analyzers: AnalyzerConfig; analyzers: AnalyzerConfig;
min_space_to_start_recording_mb: number;
min_space_to_continue_recording_mb: number;
} }
export async function req(method: string, url: string, json_body?: unknown): Promise<string> { export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
@@ -101,14 +104,6 @@ export async function test_notification(): Promise<void> {
} }
} }
export interface RouteStatus {
has_default_route: boolean;
}
export async function get_route_status(): Promise<RouteStatus> {
return JSON.parse(await req('GET', '/api/route-status'));
}
export interface TimeResponse { export interface TimeResponse {
system_time: string; system_time: string;
adjusted_time: string; adjusted_time: string;
+30 -5
View File
@@ -10,10 +10,8 @@
import RecordingControls from '$lib/components/RecordingControls.svelte'; import RecordingControls from '$lib/components/RecordingControls.svelte';
import ConfigForm from '$lib/components/ConfigForm.svelte'; import ConfigForm from '$lib/components/ConfigForm.svelte';
import ActionErrors from '$lib/components/ActionErrors.svelte'; import ActionErrors from '$lib/components/ActionErrors.svelte';
import IPRouteAlert from '$lib/components/IPRouteAlert.svelte';
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte'; import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
import LogView from '$lib/components/LogView.svelte'; import LogView from '$lib/components/LogView.svelte';
import WarningIcon from '$lib/components/WarningIcon.svelte';
let manager: AnalysisManager = new AnalysisManager(); let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false); let loaded = $state(false);
@@ -180,7 +178,21 @@
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between" class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
> >
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"> <span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
<WarningIcon class="w-8 h-8 text-red-600" /> <svg
class="w-8 h-8 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Connection Error Connection Error
</span> </span>
<span <span
@@ -196,7 +208,6 @@
</div> </div>
{/if} {/if}
<ActionErrors /> <ActionErrors />
<IPRouteAlert />
<ClockDriftAlert /> <ClockDriftAlert />
{#if loaded} {#if loaded}
<div class="flex flex-col lg:flex-row gap-4"> <div class="flex flex-col lg:flex-row gap-4">
@@ -214,7 +225,21 @@
<span <span
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600" class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
> >
<WarningIcon class="w-8 h-8 text-red-600" /> <svg
class="w-8 h-8 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
WARNING: Not Running WARNING: Not Running
</span> </span>
<span> <span>
+7
View File
@@ -28,6 +28,12 @@ ntfy_url = ""
# What notification types to enable. Does nothing if the above ntfy_url is not set. # What notification types to enable. Does nothing if the above ntfy_url is not set.
enabled_notifications = ["Warning", "LowBattery"] enabled_notifications = ["Warning", "LowBattery"]
# Disk Space Management
# Minimum free space (MB) required to start recording
min_space_to_start_recording_mb = 1
# Minimum free space (MB) to continue recording (stops if below this)
min_space_to_continue_recording_mb = 1
# Analyzer Configuration # Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics # Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details # See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
@@ -39,3 +45,4 @@ null_cipher = true
nas_null_cipher = true nas_null_cipher = true
incomplete_sib = true incomplete_sib = true
test_analyzer = false test_analyzer = false
diagnostic_analyzer = true
+2 -1
View File
@@ -1,6 +1,6 @@
# Summary # Summary
[Introduction](./introduction.md) - [Introduction](./introduction.md)
- [Support, feedback, and community](./support-feedback-community.md) - [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md) - [Frequently Asked Questions](./faq.md)
- [Installation](./installation.md) - [Installation](./installation.md)
@@ -22,3 +22,4 @@
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md) - [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
- [PinePhone and PinePhone Pro](./pinephone.md) - [PinePhone and PinePhone Pro](./pinephone.md)
- [Moxee Hotspot](./moxee.md) - [Moxee Hotspot](./moxee.md)
- [REST API Documentation](./api-docs.md)
+5
View File
@@ -0,0 +1,5 @@
# REST API Documentation
The rayhunter daemon has [REST API documentation](./api-docs/) available in the interactive swagger-ui.
>**Note:** API endpoints are subject to change as needs arise, though we will try to keep them as stable as possible and notify about breaking changes in the changelogs for new versions.
+6
View File
@@ -0,0 +1,6 @@
.warning-box {
padding: 0.75em 1em;
border-left: 4px solid #e33;
border-radius: 4px;
background-color: color-mix(in srgb, currentColor 10%, transparent);
}
+3
View File
@@ -73,6 +73,9 @@ This analyzer tests whether the SIB1 message contains a complete SIB chain (SIB3
On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **IMSI Requested** detection it should be considered as a strong indicator of malicious activity. On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **IMSI Requested** detection it should be considered as a strong indicator of malicious activity.
### Diagnostic Information
This analyzer displays some diagnostic information about when your device connects and disconnects from certain towers. It is helpful for analysis of suspicious PCAPs. The informational warnings in here can safely be ignored until there is a low, medium, or high severity warning.
### Test Analyzer ### Test Analyzer
This analyzer is great for testing if your Rayhunter installation works. It will alert every time a new tower is seen (specifically every time a tower broadcasts a SIB1 message.) It is designed to be very noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working! This analyzer is great for testing if your Rayhunter installation works. It will alert every time a new tower is seen (specifically every time a tower broadcasts a SIB1 message.) It is designed to be very noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working!
+65 -50
View File
@@ -1,63 +1,78 @@
# Installing from source # Installing from source
Building Rayhunter from source, either for development or because the install script doesn't work on your system, involves a number of external dependencies. Unless you need to do this, we recommend you use our [compiled builds](https://github.com/EFForg/rayhunter/releases). Building Rayhunter from source, either for development or otherwise, involves a
number of external dependencies. Unless you need to do this, we recommend you
use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI At a high level, we have:
* Make sure to build the site with `pushd daemon/web && npm install && npm run build && popd` before building Rayhunter. If you're working directly on the frontend, `npm run dev` will allow you to test a local frontend with hot-reloading (use `http://localhost:5173` instead of `http://localhost:8080`).
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
* Install `curl` on your computer to run the install scripts. It is not needed to build binaries.
### Install Rust targets * A JS frontend written in SvelteKit (`./daemon/web/`)
* A Rust binary `rayhunter-daemon` (`./daemon/`) that runs on the device, and bundles the frontend.
* A Rust binary `installer` (`./installer`) that runs on the computer and bundles `rayhunter-daemon`.
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then, It's recommended to work either on Mac/Linux, or WSL on Windows.
- install the cross-compilation target for the device Rayhunter will run on: ## Building frontend and backend
```sh
rustup target add armv7-unknown-linux-musleabihf
```
- install the statically compiled target for your host machine to build the binary installer `serial`. First, install dependencies:
```sh
# check which toolchain you have installed by default with
rustup show
# now install the correct variant for your host platform, one of:
rustup target add aarch64-unknown-linux-musl
rustup target add armv7-unknown-linux-musleabi
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
rustup target add x86_64-pc-windows-gnu
```
Now you can root your device and install Rayhunter by running: - [Rust](https://www.rust-lang.org/tools/install)
- [Node.js/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- C compiler tools (`apt install build-essential` on Linux, `xcode-select --install` on Mac)
Then you can build everything with:
```sh ```sh
# Build the daemon binary for local development (rustcrypto TLS backend, fast compilation) ./scripts/build-dev.sh
# WARNING: The rustcrypto library, though not known to be insecure, is less well ./scripts/install-dev.sh orbic # replace 'orbic' with your device type
# tested than its counterpart and could potentially have severe issues in
# its cryptographic implementation. We therefore recommend using ring-tls in
# production builds (see below)
cargo build-daemon-firmware-devel
# To build it exactly like in CI (more mature ring TLS backend, slower compilation)
# CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
# Build rootshell
cargo build-rootshell-firmware-devel
# Replace 'orbic' with your device type if different.
# A list of possible values can be found with 'cargo run --bin installer help'.
FIRMWARE_PROFILE=firmware-devel cargo run -p installer --bin installer orbic
``` ```
### If you're on Windows or can't run the install scripts ## Hot-reloading the frontend
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183> If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
* Build the web UI using `cd daemon/web && npm install && npm run build`
* Push the scripts in `scripts/` to `/etc/init.d` on device and make a directory called `/data/rayhunter` using `adb shell` (and sshell for your root shell if you followed the steps above) You can instead run the frontend separately on your PC while the Rust parts
* You also need to copy `config.toml.in` to `/data/rayhunter/config.toml`. Uncomment the `device` line and set the value to your device type if necessary. continue running on your target device:
* Then run `./make.sh`, which will build the binary, push it over adb, and restart the device. Once it's restarted, Rayhunter should be running!
```sh
cd daemon/web
# Assumes rayhunter-daemon is listening on localhost:8080
npm run dev
# Use a custom target IP:port where the backend runs
API_TARGET=http://192.168.1.1:8080 npm run dev
```
The UI will listen on `localhost:5173` and instantly show any frontend changes
you make. Backend changes require building everything from the top (daemon and installer).
## Installer utils, getting a shell
Check `./scripts/install-dev.sh util --help`
for useful utilities for transferring files, opening shells. The exact tools
available wildly depend on the device you're working on, and they are
usually documented the relevant device's page under [Supported
Devices](./supported-devices.md).
A lot of devices run a trimmed down version of Android and have ADB (Android
Debug Bridge) support. The USB-based installers (`orbic-usb`, `pinephone`,
`uz801`) use ADB to perform the installation.
You might want to install and use actual ADB to connect to the device, push
files and generally poke around. The installer contains some tools to enable ADB:
```sh
adb kill-server
# Enables ADB on either of these devices
./scripts/install-dev.sh util tmobile-start-adb
./scripts/install-dev.sh orbic-usb
adb shell
```
Note though that we can't assist with any issues setting ADB up, _especially
not_ on Windows. There have been too many driver issues to make this the
"golden path" for most users or contributors. There have been instances where
people managed to brick their orbic devices using ADB on Windows.
+14 -1
View File
@@ -5,6 +5,16 @@ Supported in Rayhunter since version 0.6.0.
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
primarily for the US market. primarily for the US market.
<div class="warning-box">
**WARNING: These devices are known to become completely bricked by installing Rayhunter.**
Do not buy this device nor try to install _nor upgrade_ Rayhunter on it.
We're still trying to figure out what's wrong in [this discussion](https://github.com/EFForg/rayhunter/issues/865).
</div>
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot) - [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
- [Moxee product page](https://www.moxee.com/hotspot) - [Moxee product page](https://www.moxee.com/hotspot)
@@ -30,11 +40,14 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL),
Connect to the hotspot's network using WiFi or USB tethering and run: Connect to the hotspot's network using WiFi or USB tethering and run:
```sh ```sh
./installer orbic --admin-password 'mypassword' ./installer moxee --admin-password 'mypassword'
``` ```
The password (in place of `mypassword`) is under the battery. The password (in place of `mypassword`) is under the battery.
`./installer moxee` is almost the same as `./installer orbic`, it just comes
with slightly better defaults that will give you more space for recordings.
## Obtaining a shell ## Obtaining a shell
```sh ```sh
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: './rayhunter-openapi.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout",
});
};
</script>
</body>
</html>
+2
View File
@@ -26,6 +26,8 @@ You can access this UI in one of two ways:
* **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you. * **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you.
> **_NOTE:_** When downloading recordings, "Insecure download blocked" warnings can safely be ignored - this is due to Rayhunter not using HTTPS.
## Key shortcuts ## Key shortcuts
As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md). As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).
+31 -23
View File
@@ -16,7 +16,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.38.0", "@eslint/js": "^9.38.0",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.50.1", "@sveltejs/kit": "^2.53.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"eslint": "^9.38.0", "eslint": "^9.38.0",
@@ -25,7 +25,7 @@
"globals": "^16.4.0", "globals": "^16.4.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.0.0", "svelte": "^5.53.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.46.2",
@@ -1087,9 +1087,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.50.1", "version": "2.53.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.0.tgz",
"integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==", "integrity": "sha512-Brh/9h8QEg7rWIj+Nnz/2sC49NUeS8g3Qd9H5dTO3EbWG8vCEUl06jE+r5jQVDMHdr1swmCkwZkONFsWelGTpQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1098,13 +1098,12 @@
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"acorn": "^8.14.1", "acorn": "^8.14.1",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"devalue": "^5.6.2", "devalue": "^5.6.3",
"esm-env": "^1.2.2", "esm-env": "^1.2.2",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"magic-string": "^0.30.5", "magic-string": "^0.30.5",
"mrmime": "^2.0.0", "mrmime": "^2.0.0",
"sade": "^1.8.1", "set-cookie-parser": "^3.0.0",
"set-cookie-parser": "^2.6.0",
"sirv": "^3.0.0" "sirv": "^3.0.0"
}, },
"bin": { "bin": {
@@ -1115,10 +1114,10 @@
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.0.0", "@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@opentelemetry/api": { "@opentelemetry/api": {
@@ -1736,6 +1735,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.2", "version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
@@ -2271,9 +2277,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.2", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2523,9 +2529,9 @@
} }
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.1.2", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3773,9 +3779,9 @@
} }
}, },
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3853,9 +3859,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.43.2", "version": "5.53.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.2.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.0.tgz",
"integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==", "integrity": "sha512-7dhHkSamGS2vtoBmIW2hRab+gl5Z60alEHZB4910ePqqJNxAWnDAxsofVmlZ2tREmWyHNE+A1nCKwICAquoD2A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3863,12 +3869,14 @@
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aria-query": "^5.3.1", "aria-query": "^5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.6.3",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.1.0", "esrap": "^2.2.2",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
+2 -2
View File
@@ -24,7 +24,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.38.0", "@eslint/js": "^9.38.0",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.50.1", "@sveltejs/kit": "^2.53.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"eslint": "^9.38.0", "eslint": "^9.38.0",
@@ -33,7 +33,7 @@
"globals": "^16.4.0", "globals": "^16.4.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.0.0", "svelte": "^5.53.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.46.2",
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "installer-gui" name = "installer-gui"
version = "0.10.0" version = "0.10.2"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -21,4 +21,5 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
anyhow = "1.0.100" anyhow = "1.0.100"
shlex = "1"
installer = { path = "../../installer" } installer = { path = "../../installer" }
+3 -2
View File
@@ -1,10 +1,11 @@
use anyhow::Context;
use tauri::Emitter; use tauri::Emitter;
async fn run_installer(app_handle: tauri::AppHandle, args: String) -> anyhow::Result<()> { async fn run_installer(app_handle: tauri::AppHandle, args: String) -> anyhow::Result<()> {
let args_vec = shlex::split(&args).context("Failed to parse arguments: unclosed quote")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
installer::run_with_callback( installer::run_with_callback(
// TODO: we should split using something similar to shlex in python args_vec.iter().map(|s| s.as_str()),
args.split_whitespace(),
Some(Box::new(move |output| { Some(Box::new(move |output| {
app_handle app_handle
.emit("installer-output", output) .emit("installer-output", output)
+3
View File
@@ -81,6 +81,9 @@
<input <input
class="mr-1 px-5 py-2 rounded-lg shadow-md" class="mr-1 px-5 py-2 rounded-lg shadow-md"
placeholder="Enter CLI installer args..." placeholder="Enter CLI installer args..."
autocapitalize="off"
autocorrect="off"
spellcheck="false"
bind:value={installerArgs} bind:value={installerArgs}
/> />
<button <button
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "installer" name = "installer"
version = "0.10.0" version = "0.10.2"
edition = "2024" edition = "2024"
[lib] [lib]
+8 -1
View File
@@ -4,7 +4,14 @@ use std::process::exit;
fn main() { fn main() {
println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN"); println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN");
println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE"); println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE");
let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| "firmware".to_string()); let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| {
// Default to firmware-devel for debug builds, firmware for release builds
if std::env::var("PROFILE").as_deref() == Ok("release") {
"firmware".to_string()
} else {
"firmware-devel".to_string()
}
});
let include_dir = Path::new(env!("CARGO_MANIFEST_DIR")) let include_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../target/armv7-unknown-linux-musleabihf") .join("../target/armv7-unknown-linux-musleabihf")
.join(&profile); .join(&profile);
+118 -5
View File
@@ -1,9 +1,9 @@
use std::future::Future; use std::future::Future;
use std::net::SocketAddr; use std::net::SocketAddr;
use anyhow::Result; use anyhow::{Result, bail};
use crate::output::println; use crate::output::{print, println};
/// Abstraction for device communication (telnet or ADB) /// Abstraction for device communication (telnet or ADB)
pub trait DeviceConnection { pub trait DeviceConnection {
@@ -17,19 +17,20 @@ pub trait DeviceConnection {
/// Check if a file exists using a DeviceConnection /// Check if a file exists using a DeviceConnection
pub async fn file_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool { pub async fn file_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
conn.run_command(&format!("test -f {path} && echo exists || echo missing")) conn.run_command(&format!("test -f '{path}' && echo exists || echo missing"))
.await .await
.map(|output| output.contains("exists")) .map(|output| output.contains("exists"))
.unwrap_or(false) .unwrap_or(false)
} }
/// Shared config installation logic /// Shared config installation logic. Installs to /data/rayhunter/config.toml which resolves
/// through the symlink to the actual data directory.
pub async fn install_config<C: DeviceConnection>( pub async fn install_config<C: DeviceConnection>(
conn: &mut C, conn: &mut C,
config_path: &str,
device_type: &str, device_type: &str,
reset_config: bool, reset_config: bool,
) -> Result<()> { ) -> Result<()> {
let config_path = "/data/rayhunter/config.toml";
if reset_config || !file_exists(conn, config_path).await { if reset_config || !file_exists(conn, config_path).await {
let config = crate::CONFIG_TOML.replace( let config = crate::CONFIG_TOML.replace(
r#"#device = "orbic""#, r#"#device = "orbic""#,
@@ -42,6 +43,118 @@ pub async fn install_config<C: DeviceConnection>(
Ok(()) Ok(())
} }
/// Check if a directory exists using a DeviceConnection
pub async fn dir_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
conn.run_command(&format!("test -d '{path}' && echo exists || echo missing"))
.await
.map(|output| output.contains("exists"))
.unwrap_or(false)
}
/// Check if a path is a symlink using a DeviceConnection
pub async fn is_symlink<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
conn.run_command(&format!("test -L '{path}' && echo yes || echo no"))
.await
.map(|output| output.contains("yes"))
.unwrap_or(false)
}
/// Read the target of a symlink using a DeviceConnection
pub async fn readlink<C: DeviceConnection>(conn: &mut C, path: &str) -> Result<String> {
// Use a prefix marker to find the actual output line, since some shells (TP-Link) echo
// back the command and run_command appends protocol lines.
let output = conn
.run_command(&format!("echo RL:$(readlink '{path}')"))
.await?;
for line in output.lines() {
if let Some(target) = line.trim().strip_prefix("RL:") {
return Ok(target.to_string());
}
}
bail!("unexpected readlink output: {output:?}");
}
/// Set up the data directory at `data_dir` and create a symlink from `/data/rayhunter` to it.
///
/// Handles migration from old locations:
/// - If `/data/rayhunter` is a real directory, moves its contents to `data_dir`
/// - If `/data/rayhunter` is a symlink to a different location, moves from the old target
/// - If `/data/rayhunter` doesn't exist, just creates the symlink
/// - If `/data/rayhunter` is a symlink to `data_dir`, does nothing
pub async fn setup_data_directory<C: DeviceConnection>(conn: &mut C, data_dir: &str) -> Result<()> {
if data_dir == "/data/rayhunter" {
bail!("data_dir must not be /data/rayhunter");
}
if data_dir.contains("'") {
bail!("data_dir must not contain an apostrophe (')");
}
// Determine where old data lives, if anywhere
let old_data_source = if is_symlink(conn, "/data/rayhunter").await {
let current_target = readlink(conn, "/data/rayhunter").await?;
if current_target == data_dir {
println!("Data directory already configured at {data_dir}");
return Ok(());
}
conn.run_command("rm -f /data/rayhunter").await?;
// The old symlink target is where data actually lives
if dir_exists(conn, &current_target).await {
Some(current_target)
} else {
None
}
} else if dir_exists(conn, "/data/rayhunter").await {
if dir_exists(conn, data_dir).await {
bail!("Both /data/rayhunter and {data_dir} exist and are directories.");
}
// Real directory (pre-migration Orbic state)
Some("/data/rayhunter".to_string())
} else {
None
};
// Migrate old data if present
if let Some(old_source) = &old_data_source {
// Stop rayhunter-daemon so it doesn't write during migration.
// The device will be rebooted at the end of installation anyway.
print!("Stopping rayhunter-daemon ... ");
let _ = conn
.run_command("/etc/init.d/rayhunter_daemon stop 2>/dev/null; true")
.await;
println!("ok");
print!("Migrating data from {old_source} to {data_dir} ... ");
// mv old data into its place. If source and destination are on the same filesystem,
// this is an instant rename.
// XXX: DeviceConnection::run_command does not expose the exit code of the ran command. It
// probably should, or a utility for it should exist?
let mv_output = conn
.run_command(&format!("mv '{old_source}' '{data_dir}' && echo MV_OK"))
.await?;
if mv_output.contains("MV_OK") {
println!("ok");
} else {
bail!("Failed to move data from {old_source} to {data_dir}:\n{mv_output}");
}
} else {
// No migration needed, just ensure the target directory exists
conn.run_command(&format!("mkdir -p '{data_dir}'")).await?;
}
// Create the symlink
print!("Creating symlink /data/rayhunter -> {data_dir} ... ");
conn.run_command("mkdir -p /data").await?;
conn.run_command(&format!("ln -sf '{data_dir}' /data/rayhunter"))
.await?;
println!("ok");
Ok(())
}
/// Telnet-based connection wrapper /// Telnet-based connection wrapper
pub struct TelnetConnection { pub struct TelnetConnection {
pub addr: SocketAddr, pub addr: SocketAddr,
+41 -2
View File
@@ -6,6 +6,7 @@ use env_logger::Env;
use anyhow::bail; use anyhow::bail;
mod connection; mod connection;
mod moxee;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
mod orbic; mod orbic;
mod orbic_auth; mod orbic_auth;
@@ -40,9 +41,11 @@ enum Command {
/// Install rayhunter on the Orbic RC400L using the legacy USB+ADB-based installer. /// Install rayhunter on the Orbic RC400L using the legacy USB+ADB-based installer.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
OrbicUsb(InstallOrbic), OrbicUsb(InstallOrbic),
/// Install rayhunter on the Orbic RC400L or Moxee Hotspot via network. /// Install rayhunter on the Orbic RC400L via network.
#[clap(alias = "orbic-network")] #[clap(alias = "orbic-network")]
Orbic(OrbicNetworkArgs), Orbic(OrbicNetworkArgs),
/// Install rayhunter on the Moxee Hotspot via network.
Moxee(MoxeeArgs),
/// Install rayhunter on the TMobile TMOHS1. /// Install rayhunter on the TMobile TMOHS1.
Tmobile(TmobileArgs), Tmobile(TmobileArgs),
/// Install rayhunter on the Uz801. /// Install rayhunter on the Uz801.
@@ -84,6 +87,12 @@ struct InstallTpLink {
/// Overwrite config.toml even if it already exists on the device. /// Overwrite config.toml even if it already exists on the device.
#[arg(long)] #[arg(long)]
reset_config: bool, reset_config: bool,
/// Override the data directory path. Defaults to /cache/rayhunter-data (or SD card path when
/// SD card is used). Must not be /data/rayhunter, which lives on a storage partition that's
/// too small for normal Rayhunter operation.
#[arg(long)]
data_dir: Option<String>,
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -110,6 +119,35 @@ struct OrbicNetworkArgs {
/// Overwrite config.toml even if it already exists on the device. /// Overwrite config.toml even if it already exists on the device.
#[arg(long)] #[arg(long)]
reset_config: bool, reset_config: bool,
/// Override the data directory path. Defaults to /data/rayhunter-data.
/// Must not be /data/rayhunter.
#[arg(long)]
data_dir: Option<String>,
}
#[derive(Parser, Debug)]
struct MoxeeArgs {
/// IP address for Moxee admin interface, if custom.
#[arg(long, default_value = "192.168.1.1")]
admin_ip: String,
/// Admin username for authentication.
#[arg(long, default_value = "admin")]
admin_username: String,
/// Admin password for authentication.
#[arg(long)]
admin_password: Option<String>,
/// Overwrite config.toml even if it already exists on the device.
#[arg(long)]
reset_config: bool,
/// Override the data directory path. Defaults to /cache/rayhunter-data.
/// Must not be /data/rayhunter.
#[arg(long)]
data_dir: Option<String>,
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -245,7 +283,8 @@ async fn run(args: Args) -> Result<(), Error> {
.context("Failed to install rayhunter on the Pinephone's Quectel modem")?, .context("Failed to install rayhunter on the Pinephone's Quectel modem")?,
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
Command::OrbicUsb(args) => orbic::install(args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L (USB installer)")?, Command::OrbicUsb(args) => orbic::install(args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L (USB installer)")?,
Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L")?, Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config, args.data_dir).await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
Command::Moxee(args) => moxee::install(args).await.context("\nFailed to install rayhunter on the Moxee Hotspot")?,
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?, Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
Command::Util(subcommand) => { Command::Util(subcommand) => {
match subcommand.command { match subcommand.command {
+15
View File
@@ -0,0 +1,15 @@
use anyhow::Result;
use crate::MoxeeArgs;
pub async fn install(args: MoxeeArgs) -> Result<()> {
let data_dir = args.data_dir.or(Some("/cache/rayhunter-data".to_string()));
crate::orbic_network::install(
args.admin_ip,
args.admin_username,
args.admin_password,
args.reset_config,
data_dir,
)
.await
}
+1 -7
View File
@@ -158,13 +158,7 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
let mut conn = AdbConnection { let mut conn = AdbConnection {
device: &mut adb_device, device: &mut adb_device,
}; };
install_config( install_config(&mut conn, "orbic", reset_config).await?;
&mut conn,
"/data/rayhunter/config.toml",
"orbic",
reset_config,
)
.await?;
} }
install_file( install_file(
+12 -13
View File
@@ -8,7 +8,7 @@ use serde::Deserialize;
use tokio::time::sleep; use tokio::time::sleep;
use crate::RAYHUNTER_DAEMON_INIT; use crate::RAYHUNTER_DAEMON_INIT;
use crate::connection::{TelnetConnection, install_config}; use crate::connection::{TelnetConnection, install_config, setup_data_directory};
use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password}; use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password};
use crate::output::{eprintln, print, println}; use crate::output::{eprintln, print, println};
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file}; use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
@@ -97,7 +97,10 @@ async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Re
.context("Failed to parse login response")?; .context("Failed to parse login response")?;
if login_result.retcode != 0 { if login_result.retcode != 0 {
bail!("Login failed with retcode: {}", login_result.retcode); match login_result.retcode {
201 => bail!("Login failed: incorrect password"),
code => bail!("Login failed with retcode: {}", code),
}
} }
// Step 4: Exploit using authenticated session // Step 4: Exploit using authenticated session
@@ -144,6 +147,7 @@ pub async fn install(
admin_username: String, admin_username: String,
admin_password: Option<String>, admin_password: Option<String>,
reset_config: bool, reset_config: bool,
data_dir: Option<String>,
) -> Result<()> { ) -> Result<()> {
let Some(admin_password) = admin_password else { let Some(admin_password) = admin_password else {
eprintln!( eprintln!(
@@ -167,7 +171,8 @@ pub async fn install(
wait_for_telnet(&admin_ip).await?; wait_for_telnet(&admin_ip).await?;
println!("done"); println!("done");
setup_rayhunter(&admin_ip, reset_config).await let data_dir = data_dir.unwrap_or_else(|| "/data/rayhunter-data".to_string());
setup_rayhunter(&admin_ip, reset_config, &data_dir).await
} }
async fn wait_for_telnet(admin_ip: &str) -> Result<()> { async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
@@ -191,7 +196,7 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
Ok(()) Ok(())
} }
async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> { async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> {
let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?; let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
@@ -205,7 +210,8 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> {
) )
.await?; .await?;
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", false).await?; let mut conn = TelnetConnection::new(addr, false);
setup_data_directory(&mut conn, data_dir).await?;
telnet_send_file( telnet_send_file(
addr, addr,
@@ -215,14 +221,7 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> {
) )
.await?; .await?;
let mut conn = TelnetConnection::new(addr, false); install_config(&mut conn, "orbic", reset_config).await?;
install_config(
&mut conn,
"/data/rayhunter/config.toml",
"orbic",
reset_config,
)
.await?;
telnet_send_file( telnet_send_file(
addr, addr,
+46 -39
View File
@@ -18,7 +18,7 @@ use serde::Deserialize;
use tokio::time::sleep; use tokio::time::sleep;
use crate::InstallTpLink; use crate::InstallTpLink;
use crate::connection::{TelnetConnection, install_config}; use crate::connection::{TelnetConnection, install_config, setup_data_directory};
use crate::output::println; use crate::output::println;
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file}; use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
@@ -30,10 +30,19 @@ pub async fn main_tplink(
admin_ip, admin_ip,
sdcard_path, sdcard_path,
reset_config, reset_config,
data_dir,
}: InstallTpLink, }: InstallTpLink,
) -> Result<(), Error> { ) -> Result<(), Error> {
let is_v3 = start_telnet(&admin_ip).await?; let is_v3 = start_telnet(&admin_ip).await?;
tplink_run_install(skip_sdcard, admin_ip, sdcard_path, is_v3, reset_config).await tplink_run_install(
skip_sdcard,
admin_ip,
sdcard_path,
is_v3,
reset_config,
data_dir,
)
.await
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -114,19 +123,15 @@ async fn tplink_run_install(
mut sdcard_path: String, mut sdcard_path: String,
is_v3: bool, is_v3: bool,
reset_config: bool, reset_config: bool,
cli_data_dir: Option<String>,
) -> Result<(), Error> { ) -> Result<(), Error> {
println!("Connecting via telnet to {admin_ip}"); println!("Connecting via telnet to {admin_ip}");
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
if skip_sdcard { let data_dir = if let Some(dir) = cli_data_dir {
sdcard_path = "/data/rayhunter-data".to_owned(); dir
telnet_send_command( } else if skip_sdcard {
addr, "/cache/rayhunter-data".to_owned()
&format!("mkdir -p {sdcard_path}"),
"exit code 0",
true,
)
.await?
} else { } else {
if sdcard_path.is_empty() { if sdcard_path.is_empty() {
let try_paths = [ let try_paths = [
@@ -146,9 +151,12 @@ async fn tplink_run_install(
} }
if sdcard_path.is_empty() { if sdcard_path.is_empty() {
// This error message is shown when the installer cannot figure out where this
// device _would_ mount an SD card, regardless of whether the user did insert one.
// If we get here, it's likely the installer was never tested for this hardware
// version.
anyhow::bail!( anyhow::bail!(
"Unable to determine sdcard path. Rayhunter needs a FAT-formatted SD card to function.\n\n\ "Unable to determine sdcard path. This is a bug. Please file an issue with your hardware version.\n\n\
If you already inserted a FAT formatted SD card, this is a bug. Please file an issue with your hardware version.\n\n\
The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\ The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
...but none of them exist.\n\n\ ...but none of them exist.\n\n\
At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself." At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
@@ -166,49 +174,43 @@ async fn tplink_run_install(
.await .await
.is_err() .is_err()
{ {
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?; // Try to mount the SD card, and if that fails we assume the user didn't insert one.
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few hours. Insert one and rerun this installer, or pass --skip-sdcard")?;
} else { } else {
println!("sdcard already mounted"); println!("sdcard already mounted");
} }
}
// there is too little space on the internal flash to store anything, but the initrd script sdcard_path
// expects things to be at this location };
telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0", true).await?;
telnet_send_command(addr, "mkdir -p /data", "exit code 0", true).await?;
telnet_send_command(
addr,
&format!("ln -sf {sdcard_path} /data/rayhunter"),
"exit code 0",
true,
)
.await?;
let mut conn = TelnetConnection::new(addr, true); let mut conn = TelnetConnection::new(addr, true);
let config_path = format!("{sdcard_path}/config.toml"); setup_data_directory(&mut conn, &data_dir).await?;
install_config(&mut conn, &config_path, "tplink", reset_config).await?;
install_config(&mut conn, "tplink", reset_config).await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
telnet_send_file( telnet_send_file(
addr, addr,
&format!("{sdcard_path}/rayhunter-daemon"), "/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin, rayhunter_daemon_bin,
true, true,
) )
.await?; .await?;
let init_script = get_rayhunter_daemon(if skip_sdcard { None } else { Some(&data_dir) });
telnet_send_file( telnet_send_file(
addr, addr,
"/etc/init.d/rayhunter_daemon", "/etc/init.d/rayhunter_daemon",
get_rayhunter_daemon(&sdcard_path).as_bytes(), init_script.as_bytes(),
true, true,
) )
.await?; .await?;
telnet_send_command( telnet_send_command(
addr, addr,
&format!("chmod ugo+x {sdcard_path}/rayhunter-daemon"), "chmod ugo+x /data/rayhunter/rayhunter-daemon",
"exit code 0", "exit code 0",
true, true,
) )
@@ -368,18 +370,19 @@ async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
Ok(()) Ok(())
} }
fn get_rayhunter_daemon(sdcard_path: &str) -> String { fn get_rayhunter_daemon(sdcard_path: Option<&str>) -> String {
// Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And // Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And
// changing the order in which daemons are started up seems to not work reliably. // changing the order in which daemons are started up seems to not work reliably.
// //
// This part of the daemon dynamically generated because we may have to eventually add logic // This part of the daemon dynamically generated because we may have to eventually add logic
// specific to a particular hardware revision here. // specific to a particular hardware revision here.
crate::RAYHUNTER_DAEMON_INIT.replace( let prestart = match sdcard_path {
"#RAYHUNTER-PRESTART", Some(path) => {
&format!( format!("(mount /dev/mmcblk0p1 {path} || true) 2>&1 | tee /tmp/rayhunter-mount.log")
"(mount /dev/mmcblk0p1 {sdcard_path} || true) 2>&1 | tee /tmp/rayhunter-mount.log" }
), None => String::new(),
) };
crate::RAYHUNTER_DAEMON_INIT.replace("#RAYHUNTER-PRESTART", &prestart)
} }
/// Root the TP-Link device and open an interactive shell /// Root the TP-Link device and open an interactive shell
@@ -390,6 +393,10 @@ pub async fn shell(admin_ip: &str) -> Result<(), Error> {
#[test] #[test]
fn test_get_rayhunter_daemon() { fn test_get_rayhunter_daemon() {
let s = get_rayhunter_daemon("/media/card"); let s = get_rayhunter_daemon(Some("/media/card"));
assert!(s.contains("mount /dev/mmcblk0p1 /media/card")); assert!(s.contains("mount /dev/mmcblk0p1 /media/card"));
let s = get_rayhunter_daemon(None);
assert!(!s.contains("mmcblk0p1"));
assert!(!s.contains("#RAYHUNTER-PRESTART"));
} }
+57 -36
View File
@@ -19,53 +19,61 @@ pub async fn telnet_send_command_with_output(
command: &str, command: &str,
wait_for_prompt: bool, wait_for_prompt: bool,
) -> Result<String> { ) -> Result<String> {
if command.contains('\n') {
bail!("multi-line commands are not allowed");
}
let stream = TcpStream::connect(addr).await?; let stream = TcpStream::connect(addr).await?;
let (mut reader, mut writer) = stream.into_split(); let (mut reader, mut writer) = stream.into_split();
if wait_for_prompt { if wait_for_prompt {
// Wait for initial '#' prompt from telnetd // Wait for the shell prompt. This also consumes any telnet IAC negotiation
loop { // the server sends at connection start, and ensures the shell is ready
let mut next_byte = 0; // for input.
reader while reader.read_u8().await? != b'#' {}
.read_exact(std::slice::from_mut(&mut next_byte))
.await?;
if next_byte == b'#' {
break;
}
}
} }
writer.write_all(command.as_bytes()).await?; // This contraption is there so we clearly know where the command output starts and ends,
// by quoting the 'exit' here, we ensure that we do not read our own command line back as // skipping telnet echoing the command back using START, and terminating the connection right
// "output" before we even hit enter, but the actual result of executing the echo. // after the command exits.
writer //
.write_all(b"; echo command done, 'exit' code $?\r\n") // 'TELNET' is quoted so that when the command gets echoed back, it does not match against
.await?; // RAYHUNTER_TELNET_COMMAND_DONE search string.
writer.write_all(format!("echo RAYHUNTER_'TELNET'_COMMAND_START; {command}; echo RAYHUNTER_'TELNET'_COMMAND_DONE\r\n").as_bytes()).await?;
let mut read_buf = Vec::new(); let mut read_buf = Vec::new();
let _ = timeout(Duration::from_secs(10), async { timeout(Duration::from_secs(10), async {
let mut buf = [0; 4096];
loop { loop {
let Ok(bytes_read) = reader.read(&mut buf).await else { let Ok(byte) = reader.read_u8().await else {
break; break;
}; };
let bytes = &buf[..bytes_read]; read_buf.push(byte);
if bytes.is_empty() {
continue;
}
read_buf.extend(bytes);
// when we see this string we know the command is done and can terminate. // when we see this string we know the command is done and can terminate.
// even if we sent command; exit, certain "telnet-like" shells (like nc contraptions) // even if we sent command; exit, certain "telnet-like" shells (like nc contraptions)
// may not terminate the connection appropriately on their own. // may not terminate the connection appropriately on their own.
let response = String::from_utf8_lossy(&read_buf); if byte == b'\n' {
if response.contains("command done, exit code ") { let response = String::from_utf8_lossy(&read_buf);
break; if response.contains("RAYHUNTER_TELNET_COMMAND_DONE") {
break;
}
} }
} }
}) })
.await; .await
let string = String::from_utf8_lossy(&read_buf).to_string(); .context("command timed out after 10 seconds")?;
Ok(string) let string = String::from_utf8_lossy(&read_buf);
let start = string.rfind("RAYHUNTER_TELNET_COMMAND_START");
let end = string.rfind("RAYHUNTER_TELNET_COMMAND_DONE");
let string = match (start, end) {
(Some(start), Some(end)) => {
// skip past the START marker and the trailing \r\n of the echoed command line
let start = start + "RAYHUNTER_TELNET_COMMAND_START".len();
string[start..end].trim_start_matches(['\r', '\n'])
}
_ => bail!("failed to parse command output from string: {string:?}"),
};
Ok(string.to_string())
} }
pub async fn telnet_send_command( pub async fn telnet_send_command(
@@ -74,7 +82,8 @@ pub async fn telnet_send_command(
expected_output: &str, expected_output: &str,
wait_for_prompt: bool, wait_for_prompt: bool,
) -> Result<()> { ) -> Result<()> {
let output = telnet_send_command_with_output(addr, command, wait_for_prompt).await?; let command = format!("{command}; echo command done, exit code $?");
let output = telnet_send_command_with_output(addr, &command, wait_for_prompt).await?;
if !output.contains(expected_output) { if !output.contains(expected_output) {
bail!("{expected_output:?} not found in: {output}"); bail!("{expected_output:?} not found in: {output}");
} }
@@ -93,7 +102,7 @@ pub async fn telnet_send_file(
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
telnet_send_command_with_output( telnet_send_command_with_output(
addr, addr,
&format!("nc -l -p 8081 >{filename}.tmp"), &format!("nc -l -p 8081 2>&1 >{filename}.tmp"),
wait_for_prompt, wait_for_prompt,
) )
.await .await
@@ -121,7 +130,7 @@ pub async fn telnet_send_file(
print!("attempt {attempts}... "); print!("attempt {attempts}... ");
} }
{ let send_result: Result<()> = async {
let mut stream = stream?; let mut stream = stream?;
stream.write_all(payload).await?; stream.write_all(payload).await?;
@@ -134,11 +143,23 @@ pub async fn telnet_send_file(
// application buffers here. // application buffers here.
sleep(Duration::from_millis(1000)).await; sleep(Duration::from_millis(1000)).await;
// ensure that stream is dropped before we wait for nc to terminate. Ok(())
drop(stream); }
.await;
let nc_output = handle
.await
.context("background nc writer failed")?
.context("background nc writer failed")?;
if let Err(e) = send_result {
bail!(
"Failed to send data to nc: {e}. nc output: '{}'",
nc_output.trim()
);
} }
handle.await?? nc_output
}; };
let checksum = md5::compute(payload); let checksum = md5::compute(payload);
+5 -2
View File
@@ -1,14 +1,16 @@
[package] [package]
name = "rayhunter" name = "rayhunter"
version = "0.10.0" version = "0.10.2"
edition = "2024" edition = "2024"
description = "Realtime cellular data decoding and analysis for IMSI catcher detection" description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
[lib] [lib]
name = "rayhunter" name = "rayhunter"
path = "src/lib.rs" path = "src/lib.rs"
[features]
apidocs = ["dep:utoipa"]
[dependencies] [dependencies]
bytes = "1.11.1" bytes = "1.11.1"
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
@@ -27,5 +29,6 @@ futures = { version = "0.3.30", default-features = false }
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
num_enum = "0.7.4" num_enum = "0.7.4"
utoipa = { version = "5.4.0", optional = true }
[dev-dependencies] [dev-dependencies]
+21 -2
View File
@@ -4,6 +4,7 @@ use pcap_file_tokio::pcapng::blocks::enhanced_packet::EnhancedPacketBlock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use crate::analysis::diagnostic::DiagnosticAnalyzer;
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType}; use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType};
use crate::util::RuntimeMetadata; use crate::util::RuntimeMetadata;
use crate::{diag::MessagesContainer, gsmtap_parser}; use crate::{diag::MessagesContainer, gsmtap_parser};
@@ -16,22 +17,26 @@ use super::{
test_analyzer::TestAnalyzer, test_analyzer::TestAnalyzer,
}; };
/// A list of booleans which stores information about which analyzers are enabled
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct AnalyzerConfig { pub struct AnalyzerConfig {
pub imsi_requested: bool, pub diagnostic_analyzer: bool,
pub connection_redirect_2g_downgrade: bool, pub connection_redirect_2g_downgrade: bool,
pub lte_sib6_and_7_downgrade: bool, pub lte_sib6_and_7_downgrade: bool,
pub null_cipher: bool, pub null_cipher: bool,
pub nas_null_cipher: bool, pub nas_null_cipher: bool,
pub incomplete_sib: bool, pub incomplete_sib: bool,
pub test_analyzer: bool, pub test_analyzer: bool,
pub imsi_requested: bool,
} }
impl Default for AnalyzerConfig { impl Default for AnalyzerConfig {
fn default() -> Self { fn default() -> Self {
AnalyzerConfig { AnalyzerConfig {
imsi_requested: true, imsi_requested: true,
diagnostic_analyzer: true,
connection_redirect_2g_downgrade: true, connection_redirect_2g_downgrade: true,
lte_sib6_and_7_downgrade: true, lte_sib6_and_7_downgrade: true,
null_cipher: true, null_cipher: true,
@@ -48,6 +53,7 @@ pub const REPORT_VERSION: u32 = 2;
/// ///
/// Informational does not result in any alert on the display. /// Informational does not result in any alert on the display.
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum EventType { pub enum EventType {
Informational = 0, Informational = 0,
Low = 1, Low = 1,
@@ -137,20 +143,29 @@ pub trait Analyzer {
fn get_version(&self) -> u32; fn get_version(&self) -> u32;
} }
/// Specific information on a given analyzer
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct AnalyzerMetadata { pub struct AnalyzerMetadata {
/// The analyzer name
pub name: String, pub name: String,
/// A description of what the analyzer does
pub description: String, pub description: String,
/// The deployed version of the analyzer code
pub version: u32, pub version: u32,
} }
/// The metadata for an analyzed report
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[serde(default)] #[serde(default)]
#[derive(Default)] #[derive(Default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct ReportMetadata { pub struct ReportMetadata {
/// A vector array of which analyzers were in use for the analysis
pub analyzers: Vec<AnalyzerMetadata>, pub analyzers: Vec<AnalyzerMetadata>,
/// The runtime metadata for rayhunter during the recording and analysis
pub rayhunter: RuntimeMetadata, pub rayhunter: RuntimeMetadata,
/// The version of the reporting format used
// anytime the format of the report changes, bump this by 1 // anytime the format of the report changes, bump this by 1
// //
// the default is 0. we consider our legacy (unversioned) heuristics to be v0 -- this'll let us // the default is 0. we consider our legacy (unversioned) heuristics to be v0 -- this'll let us
@@ -346,6 +361,10 @@ impl Harness {
harness.add_analyzer(Box::new(TestAnalyzer {})) harness.add_analyzer(Box::new(TestAnalyzer {}))
} }
if analyzer_config.diagnostic_analyzer {
harness.add_analyzer(Box::new(DiagnosticAnalyzer {}));
}
harness harness
} }
+166
View File
@@ -0,0 +1,166 @@
use crate::analysis::analyzer::{Analyzer, Event, EventType};
use crate::analysis::information_element::{InformationElement, LteInformationElement};
use pycrate_rs::nas::NASMessage;
use pycrate_rs::nas::emm::EMMMessage;
use pycrate_rs::nas::generated::emm::emm_attach_reject::EMMCauseEMMCause as AttachRejectEMMCause;
use pycrate_rs::nas::generated::emm::emm_detach_request_mt::EPSDetachTypeMTType;
use pycrate_rs::nas::generated::emm::emm_service_reject::EMMCauseEMMCause as ServiceRejectEMMCause;
use pycrate_rs::nas::generated::emm::emm_tracking_area_update_reject::EMMCauseEMMCause as TAURejectEMMCause;
use std::borrow::Cow;
pub struct DiagnosticAnalyzer;
impl Default for DiagnosticAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl DiagnosticAnalyzer {
pub fn new() -> Self {
DiagnosticAnalyzer
}
fn is_imsi_exposing_nas(&self, nas_msg: &NASMessage) -> bool {
match nas_msg {
NASMessage::EMMMessage(emm_msg) => match emm_msg {
EMMMessage::EMMIdentityRequest(_) => true, // Alert on all identity requests (IMSI, IMEI, IMEISV)
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
matches!(
reject.emm_cause.inner,
TAURejectEMMCause::IllegalUE
| TAURejectEMMCause::IllegalME
| TAURejectEMMCause::EPSServicesNotAllowed
| TAURejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
| TAURejectEMMCause::TrackingAreaNotAllowed
| TAURejectEMMCause::EPSServicesNotAllowedInThisPLMN
| TAURejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
EMMMessage::EMMAttachReject(reject) => {
matches!(
reject.emm_cause.inner,
AttachRejectEMMCause::IllegalUE
| AttachRejectEMMCause::IllegalME
| AttachRejectEMMCause::EPSServicesNotAllowed
| AttachRejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
| AttachRejectEMMCause::PLMNNotAllowed
| AttachRejectEMMCause::TrackingAreaNotAllowed
| AttachRejectEMMCause::RoamingNotAllowedInThisTrackingArea
| AttachRejectEMMCause::EPSServicesNotAllowedInThisPLMN
| AttachRejectEMMCause::NoSuitableCellsInTrackingArea
| AttachRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
EMMMessage::EMMDetachRequestMT(req) => {
// Original implementation: !(nas_eps.emm.detach_type_dl == 3)
req.eps_detach_type.inner.typ != EPSDetachTypeMTType::IMSIDetach
}
EMMMessage::EMMAttachRequest(_) => {
// just because eps_attach_type is IMSI doesn't mean that the phoen transmitted its IMSI
// It often sends the GUTI instead. We could check the req.epsid structure but it appears to actually
// not be parsed. So for now we are just ignoreing this message
// req.eps_attach_type.inner == EPSAttachTypeV::CombinedEPSIMSIAttach
false
}
EMMMessage::EMMServiceReject(reject) => {
matches!(
reject.emm_cause.inner,
ServiceRejectEMMCause::IllegalUE
| ServiceRejectEMMCause::IllegalME
| ServiceRejectEMMCause::EPSServicesNotAllowed
| ServiceRejectEMMCause::UEIdentityCannotBeDerivedByTheNetwork
| ServiceRejectEMMCause::TrackingAreaNotAllowed
| ServiceRejectEMMCause::EPSServicesNotAllowedInThisPLMN
| ServiceRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
_ => false,
},
_ => false,
}
}
}
impl Analyzer for DiagnosticAnalyzer {
fn get_name(&self) -> Cow<'_, str> {
"Diagnostic detector for messages which might lead to IMSI exposure".into()
}
fn get_description(&self) -> Cow<'_, str> {
"Catches any messages that may lead to IMSI Exposure. Can be quite noisy. \
Useful as a diagnostic for finding out why an IMSI was sent or what \
the reason for a reject message was. Not a useful indicator on its own \
but a helpful diagnostic for understanding why another indicator was \
triggered. Based on the list of IMSI exposing messages identified in \
the 'Marlin' paper."
.into()
}
fn get_version(&self) -> u32 {
1
}
fn analyze_information_element(
&mut self,
ie: &InformationElement,
_packet_num: usize,
) -> Option<Event> {
let lte_ie = match ie {
InformationElement::LTE(inner) => inner,
_ => return None,
};
match lte_ie.as_ref() {
LteInformationElement::NAS(nas_msg) => {
if self.is_imsi_exposing_nas(nas_msg) {
let message_type = match nas_msg {
NASMessage::EMMMessage(emm_msg) => match emm_msg {
EMMMessage::EMMIdentityRequest(request) => {
format!("EMM Identity Request ({:?})", request.id_type.inner)
}
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
format!(
"EMM Tracking Area Update Reject ({:?})",
reject.emm_cause.inner
)
}
EMMMessage::EMMAttachReject(reject) => {
format!("EMM Attach Reject ({:?})", reject.emm_cause.inner)
}
EMMMessage::EMMDetachRequestMT(request) => {
format!(
"EMM Detach Request ({:?}:{:?})",
request.eps_detach_type.inner, request.emm_cause.inner
)
}
EMMMessage::EMMServiceReject(reject) => {
format!("EMM Service Reject ({:?})", reject.emm_cause.inner)
}
EMMMessage::EMMAttachRequest(request) => {
format!("EPS Attach Request ({:?})", request.epsid.inner)
}
_ => "Unknown EMM Message".to_string(),
},
_ => "Unknown NAS Message".to_string(),
};
Some(Event {
event_type: EventType::Informational,
message: format!("Diagnostic: {message_type}."),
})
} else {
None
}
}
_ => None,
}
}
}
-5
View File
@@ -78,12 +78,7 @@ impl ImsiRequestedAnalyzer {
}); });
} }
// Notify on any identity reqeust (IMEI or IMSI)
(_, State::IdentityRequest) => { (_, State::IdentityRequest) => {
self.flag = Some(Event {
event_type: EventType::Informational,
message: "Identity Request happened but its not suspicious yet.".to_string(),
});
self.timeout_counter = 0; self.timeout_counter = 0;
} }
+1
View File
@@ -1,5 +1,6 @@
pub mod analyzer; pub mod analyzer;
pub mod connection_redirect_downgrade; pub mod connection_redirect_downgrade;
pub mod diagnostic;
pub mod imsi_requested; pub mod imsi_requested;
pub mod incomplete_sib; pub mod incomplete_sib;
pub mod information_element; pub mod information_element;
+2
View File
@@ -29,8 +29,10 @@ pub mod diag_device;
// re-export telcom_parser, since we use its types in our API // re-export telcom_parser, since we use its types in our API
pub use telcom_parser; pub use telcom_parser;
/// A list of the internal names of currently implemented devices
#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)] #[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum Device { pub enum Device {
Orbic, Orbic,
Tplink, Tplink,
+1
View File
@@ -5,6 +5,7 @@ use nix::sys::utsname::uname;
/// Expose binary and system information. /// Expose binary and system information.
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct RuntimeMetadata { pub struct RuntimeMetadata {
/// The cargo package version from this library's cargo.toml, e.g., "1.2.3". /// The cargo package version from this library's cargo.toml, e.g., "1.2.3".
pub rayhunter_version: String, pub rayhunter_version: String,
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rootshell" name = "rootshell"
version = "0.10.0" version = "0.10.2"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# Build Rayhunter from source for development.
# Prerequisites: Rust (rustup) and Node.js (npm).
#
# Usage: ./scripts/build-dev.sh [build|frontend|check]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
check_dependencies() {
local missing=0
if ! command -v cargo &> /dev/null; then
echo "Error: cargo not found. Install Rust via https://www.rust-lang.org/tools/install"
missing=1
fi
if ! command -v npm &> /dev/null; then
echo "Error: npm not found. Install Node.js via https://docs.npmjs.com/downloading-and-installing-node-js-and-npm"
missing=1
fi
if [ "$missing" -eq 1 ]; then
exit 1
fi
# Ensure the ARM cross-compilation target is installed
if ! rustup target list --installed | grep -q "armv7-unknown-linux-musleabihf"; then
echo "Installing ARM target (armv7-unknown-linux-musleabihf)..."
rustup target add armv7-unknown-linux-musleabihf
fi
}
build_frontend() {
echo "Building web frontend..."
pushd daemon/web > /dev/null
npm install
npm run build
popd > /dev/null
}
build_daemon() {
echo "Building daemon..."
cargo build-daemon-firmware-devel
echo "Building rootshell..."
cargo build-rootshell-firmware-devel
}
COMMAND="${1:-build}"
case "$COMMAND" in
build)
check_dependencies
build_frontend
build_daemon
echo ""
echo "Build complete! To install to a device, run:"
echo " ./scripts/install-dev.sh <device>"
echo ""
echo "Replace <device> with your device type (e.g. orbic, tplink)."
;;
frontend)
build_frontend
;;
check)
check_dependencies
;;
help|--help|-h)
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " build Build frontend, daemon, and rootshell (default)"
echo " frontend Build only the web frontend"
echo " check Check dependencies only"
;;
*)
echo "Unknown command: $COMMAND"
echo "Run '$0 help' for usage."
exit 1
;;
esac
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Install a development build of Rayhunter to a device.
# Run ./scripts/build-dev.sh first.
#
# Usage: ./scripts/install-dev.sh <device> [options...]
# Example: ./scripts/install-dev.sh orbic --admin-password mypass
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
cargo run -p installer --bin installer -- "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "telcom-parser" name = "telcom-parser"
version = "0.10.0" version = "0.10.2"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html