496 Commits

Author SHA1 Message Date
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
Cooper Quintin
ffc42f6ffd bump version to 0.10.0 2026-02-04 13:42:38 -08:00
Markus Unterwaditzer
2781b3c7ed Remove wrong timestamp correction from diag 2026-02-04 13:36:46 -08:00
Markus Unterwaditzer
fd63210bf9 Fix prettier lint 2026-02-04 13:36:46 -08:00
Markus Unterwaditzer
a271c4ddf4 Update wording in the clock sync UI (apply suggestions from code review)
Co-authored-by: Will Greenberg <willg@eff.org>
2026-02-04 13:36:46 -08:00
Markus Unterwaditzer
bef6b51e28 Add button to set current time
When there is a significant difference between the user's browser's time
and the system time, a button appears in the web UI to fix the system
time. This time will then be used to correct both data inside of PCAPs
and any metadata.

We don't actually set the system time to this value. Instead, rayhunter
adjusts any timestamps it handles by an offset. That offset defaults to
zero, and the user adjusts it by hitting the button in the web UI. The
main reason for this is device portability.

I haven't investigated whether it would actually be easy to set the real
system time. It's possible that it works the same way across all
devices.
2026-02-04 13:36:46 -08:00
Markus Unterwaditzer
781d07230c Correct FAQ entries to refer to correct shell
The orbic now has many different shells, crosslink to the docs on shells
to find the right one.

Fix #753
Ref https://github.com/EFForg/rayhunter/discussions/842
2026-02-04 10:57:22 -08:00
Markus Unterwaditzer
1f171521e4 Filter out asn1_codecs warnings in rayhunter-daemon
...and make a small UI change so that folks won't get concerned about parsing errors.

Right now all the "undecoded extensions" noise goes into
rayhunter-daemon.log, and users get concerned about it when browsing
that through the UI.
2026-02-04 10:55:54 -08:00
Harrison Marley
5b2cf3cec4 Mention Vodafone Pocket Wifi 5 availability
Added information about 'Vodafone Pocket Wifi 5' availability in Australia.
2026-02-04 10:55:39 -08:00
Cooper Quintin
62e8d4c40f appease untitaker 2026-02-04 10:54:23 -08:00
Cooper Quintin
72c19e0f04 update docs and version 2026-02-04 10:54:23 -08:00
Cooper Quintin
9b52f46c1a appease clippy 2026-02-04 10:54:23 -08:00
Cooper Quintin
51d4e86b3a cargo fmt 2026-02-04 10:54:23 -08:00
Cooper Quintin
33fafd4707 report lack of LTE neighbors 2026-02-04 10:54:23 -08:00
Cooper Quintin
6e4cbac4b1 convert int to option 2026-02-04 10:54:23 -08:00
Cooper Quintin
b453c92d6a Update lib/src/analysis/priority_2g_downgrade.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2026-02-04 10:54:23 -08:00
Cooper Quintin
733c8b227d Update lib/src/analysis/analyzer.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2026-02-04 10:54:23 -08:00
Markus Unterwaditzer
b43217ef35 Fix compilation errors 2026-02-04 10:54:23 -08:00
Cooper Quintin
40a0dec361 appease clippy 2026-02-04 10:54:23 -08:00
Cooper Quintin
b2d5ed356f cargo fmt 2026-02-04 10:54:23 -08:00
Cooper Quintin
6033757ddb compare gsm and 3g priorities to LTE priorities and only alert if higher priority or only non LTE neighbors exist 2026-02-04 10:54:23 -08:00
dependabot[bot]
6b4f98183e Bump bytes from 1.10.1 to 1.11.1 (#847)
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 20:38:53 +01:00
kmille
bd2329d6cc Show tracking area code for test heuristic in UI (#843) 2026-02-03 10:59:15 +01:00
Michael S.
d1311e0ba3 Docs: Add note about non-default credentials to install instructions and modify link target (#839) 2026-02-02 11:44:20 +01:00
Markus Unterwaditzer
75cf03d638 Fix rayhunter chugging on when diag device failed to initialize
Ref #676 -- this is a partial fix for one of the issues mentioned there.

I expect that as a result we'll get more bugreports about rayhunter not
starting, since right now those errors are "masked" by this bug.
2026-02-01 20:32:40 +01:00
Markus Unterwaditzer
be15035ad4 Use /proc/net/route instead of ip route command
Fix #811, allegedly /proc/net/route is almost always available, and no
additional dependency is needed at all.
2026-01-30 12:16:32 -08:00
Markus Unterwaditzer
a3d0d8f4f9 Better support for firmware-devel profile
Currently you have to override a bunch of paths to use firmware-devel
when building the installer. This changes that, and adds a new
FIRMWARE_PROFILE envvar that can be used to fix both rootshell and
rayhunter-daemon paths at the same time.

There is now also a new cargo command for building rootshell, similar to
how building the daemon firmware works.

I'm not sure what to do with make.sh. I have personally never used it.
2026-01-30 21:09:12 +01:00
Markus Unterwaditzer
2c30218743 Add donation button to UI 2026-01-30 10:14:57 -08:00
Markus Unterwaditzer
eb65214989 Add FAQ to issue templates 2026-01-30 10:14:57 -08:00
Markus Unterwaditzer
8d86aeb591 Restructure docs, make FAQ more prominent 2026-01-30 10:14:57 -08:00
Markus Unterwaditzer
23cef7349e Upgrade num-bigint-dig
This dependency is causing warnings during compilation, vieable with `cargo report future-incompatibilities --id 1`
2026-01-29 20:03:22 +01:00
Markus Unterwaditzer
07e0115192 Add disclaimer about flickering 2026-01-29 20:02:09 +01:00
Markus Unterwaditzer
82b53c6187 Display: Implement High Vis Mode
Work around #722 (we should probably still fix it properly)
Fix #779
2026-01-29 20:02:09 +01:00
Markus Unterwaditzer
883175aa59 Disable blank issues again
There are too many questions in the issue tracker recently.
2026-01-29 11:01:46 -08:00
Brad Warren
bd52718ea7 add {daemon,rootshell}_needed GHA outputs 2026-01-28 15:31:27 -08:00
Markus Unterwaditzer
d607c63cc8 Do not overwrite configs by default
On tplink and orbic, do not overwrite config files by default. There is
a new flag `installer orbic --reset-config` that one can use to restore
the old behavior. This fixes #778, a long-standing issue existent since
0.3.0.

The businesslogic for config file overrides is shared to some degree.
The Install trait from pinephone.rs has been moved out and renamed to
DeviceConnection for that purpose, so that `install_config` can be
shared across installers, which in turn can delegate to the trait for
running commands and copying files. This also works towards #542.

However, the pinephone and other installers have not been adapted to
support --reset-config out of fear of regressions. A future refactor by
somebody with ability to test on pinephone should probably also consider
using the same DeviceConnection impl as orbic, if possible.
2026-01-28 10:35:57 -08:00
Will Greenberg
9e08e662ff installer-gui: add an npm prepare step
seems this is required due to
https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md#minor-changes-34
2026-01-27 11:54:36 -08:00
dependabot[bot]
08920e02b8 Bump @sveltejs/kit from 2.48.3 to 2.50.1 in /installer-gui
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.48.3 to 2.50.1.
- [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.50.1/packages/kit)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 11:54:36 -08:00
Markus Unterwaditzer
7e2df91702 Fix battery warnings on unsupported devices
Fix #644, break early if battery is unsupported.
2026-01-26 11:22:47 -08:00
Markus Unterwaditzer
262f583355 Add price information to the orbic page
Since rayhunter is getting more popular let's set some expectations
around pricing. There are currently offers ranging from 20 to 90 USD,
and people should understand that the 20 USD offer is not a fake, or
anything like that.
2026-01-26 11:13:21 -08:00
Markus Unterwaditzer
9ae1563286 Add warning about default routes
I hope this puts a lot of questions about SIM cards to rest. I found
that the warning also sometimes applies to "dead" SIM cards which have
expired a long time ago.

Run `busybox ip route` to determine whether the device has an active SIM
card. That command has been manually tested on Moxee, Orbic and TP-Link.
It's prefixed with `busybox` because that makes it more likely it would
work on UZ801, though it wasn't tested there. If the command invocation
fails, the alert is suppressed and a warning is logged.

The command is only run once on pageload. It could've been part of the
status endpoint, but then the UI would poll it way too often.
2026-01-26 11:08:38 -08:00
Markus Unterwaditzer
2bd6efa503 UI: Enforce snake case for functions
It's more common to write functions in camelCase in JS, so some people
started doing it, including me. But the majority of the codebase is
snake_case, so let's enforce that.
2026-01-26 10:35:22 -08:00
Markus Unterwaditzer
e06769158b add #build-all functionality to CI 2026-01-26 10:33:56 -08:00
Markus Unterwaditzer
b341ef2d1e Fix stuck green display after restarting recording
Rayhunter keeps track of the highest-severity warning seen during a
recording, and only updates the display color when a new event
exceeds that level. When a double-tap restarts recording, this
threshold isn't reset, so it retains the old session's maximum. Since no
new event can surpass the stale threshold, the display stays stuck on
green even when warnings are detected.

Fix #794
2026-01-26 10:33:56 -08:00
dependabot[bot]
3a807f48b2 Bump @sveltejs/kit from 2.46.2 to 2.49.5 in /daemon/web
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.46.2 to 2.49.5.
- [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.49.5/packages/kit)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-15 23:55:28 +01:00
dependabot[bot]
bc3f0bf515 Bump devalue from 5.4.2 to 5.6.2 in /installer-gui
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.4.2 to 5.6.2.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.4.2...v5.6.2)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.6.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-15 23:55:13 +01:00
Rupert Carr
d3290a2c2d Add test notification endpoint and UI button
- Add POST /api/test-notification endpoint to send test to saved config URL
- Refactor send_notification to return Result instead of bool
- Add NotificationError enum for proper error handling
- Add test notification button in config UI with explanatory text
- Button tests saved configuration URL, not input field value
2026-01-07 14:18:18 +01:00
Rupert Carr
579c2c1f3f Add e2e tests for notification worker and extract send_notification helper 2026-01-07 14:18:18 +01:00
Cooper Quintin
7977a01a88 version bump 2026-01-06 09:45:58 -08:00
Cooper Quintin
78dd2f74a4 version bump 2026-01-06 09:28:40 -08:00
Markus Unterwaditzer
dd70a2a15d Add mount logs to rayhunter installer
We sometimes, but rarely, get bug reports where the sdcard fails
mounting. Write a dedicated log file for the mounting action to /tmp,
separately from the rayhunter logfile that is on the sdcard itself. That
log file is probably going to be small so it can fit in /tmp.
2026-01-06 17:42:40 +01:00
Markus Unterwaditzer
81a193959c fix another diff in behavior 2026-01-06 17:42:26 +01:00
Markus Unterwaditzer
7209910c11 Fix deku 0.20 discriminant double-read in Nas4GMessage
Applied workaround from sharksforarms/deku#305 using:
  #[deku(skip, default = "log_type")]

Found using differential fuzzing.

This may be a bug in deku.
2026-01-06 17:42:26 +01:00
Markus Unterwaditzer
3615cbf2dd Upgrade deku to 0.20
Fix #748
2026-01-05 14:32:32 -08:00
Markus Unterwaditzer
61793179e5 Fix Message parser crashes found by fuzzing
These payloads would previous cause panic on underflow.

The fuzzing setup lives in
https://github.com/untitaker/rayhunter/tree/fuzz-wip -- I can eventually
upstream it though right now it runs very inefficiently.
2025-12-09 21:31:08 +01:00
Vicente Reyes
cdc7a46162 Small grammar change 2025-12-03 09:56:25 -08:00
Markus Unterwaditzer
ffe58ab72b Remove powershell script (#715)
* Remove powershell script

Currently install.ps1 and installer are both released in the root of the
zipfile. I think that's a bit confusing. We also don't really support
the ps1 script since a while.

* Remove rootshell and config.toml.in from release folder
2025-12-03 12:08:51 +01:00
Markus Unterwaditzer
7906bf7d67 use cfmakeraw 2025-11-25 13:52:07 -08:00
Markus Unterwaditzer
5e4174c9f3 address review feedback 2025-11-25 13:52:07 -08:00
Markus Unterwaditzer
2a8fee25f9 Remove mentions of tplink-start-telnet and orbic-start-telnet 2025-11-25 13:52:07 -08:00
Markus Unterwaditzer
516e878661 fix installation instructions for orbic 2025-11-25 13:52:07 -08:00
Markus Unterwaditzer
5fbc540fa0 Implement basic telnet shell for both orbic and tplink 2025-11-25 13:52:07 -08:00
Brad Warren
676cd3c862 update installer-gui version to 0.8.0 2025-11-24 11:56:26 -08:00
Brad Warren
a8cb363112 run zizmor --fix=all . 2025-11-24 11:54:01 -08:00
dependabot[bot]
6172236a3c Bump glob from 10.4.5 to 10.5.0 in /daemon/web
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-18 21:31:21 +01:00
Markus Unterwaditzer
485d1a99f6 Revert back to the CLI using Clap more directly 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
f6e118a5cc convert arg parsing errors into stderr printing, remove main_cli 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
4cdc9961d3 fix argv0 bug and update lockfile 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
c18579583c remove shell:default permission 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
565b6d188d remove unused gen folder 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
80f12ffaaa fix github actions for windows/mac 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
3e9af006e1 remove tauri-shell entirely 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
73a5d324c4 clean up run_with_callback api 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
bb6135c682 Apply suggestion from @oopsbagel
Co-authored-by: oopsbagel <99793478+oopsbagel@users.noreply.github.com>
2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
3b44234ae1 implement installer as library and use it in gui 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
9e9fe4d392 write new main.rs 2025-11-18 21:05:22 +01:00
Markus Unterwaditzer
2c92315125 rename installer main.rs to lib.rs 2025-11-18 21:05:22 +01:00
dependabot[bot]
7bc55bf432 Bump js-yaml from 4.1.0 to 4.1.1 in /daemon/web (#705)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 15:41:31 +01:00
Will Greenberg
2a7c5b4365 Add logo SVGs
Fixes #680
2025-11-17 12:09:25 -08:00
dependabot[bot]
d48d5755c6 Bump js-yaml from 4.1.0 to 4.1.1 in /installer-gui (#702)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-16 15:31:24 +01:00
Cooper Quintin
1cf1d6d5b9 fix 642 2025-11-12 11:37:27 -08:00
Dylan Buel
c8d1b52ca7 Removed reference to deleted documentation and added language about updating to landing page (#697)
* Removed references to installing-from-release-windows.md removed in commit ea5aa6cee2

* Added language referencing the upgrade instructions in installation landing page

* Update doc/installation.md

---------

Co-authored-by: Markus Unterwaditzer <markus-github@unterwaditzer.net>
2025-11-09 12:36:36 +01:00
Markus Unterwaditzer
04efe7bb75 One pass of cargo-audit
Upgrade some yanked dependencies to non-yanked (windows-core) and ignore
the other two warnings.
2025-11-06 17:01:41 +01:00
Brad Warren
3f3b6168b3 remove license 2025-11-05 10:53:41 -08:00
Brad Warren
992a28af57 add README 2025-11-05 10:53:41 -08:00
Brad Warren
39c8844967 update ci config 2025-11-05 10:53:41 -08:00
Brad Warren
ef006d83a6 write plumbing to & from CLI installer 2025-11-05 10:53:41 -08:00
Brad Warren
bc9022530a cargo add anyhow --package installer-gui 2025-11-05 10:53:41 -08:00
Brad Warren
af2445cc38 remove frontend boilerplate 2025-11-05 10:53:41 -08:00
Brad Warren
e33f143830 add rayhunter banner 2025-11-05 10:53:41 -08:00
Brad Warren
f5360b042c set up tailwindcss 2025-11-05 10:53:41 -08:00
Brad Warren
a16fb9b678 set up eslint 2025-11-05 10:53:41 -08:00
Brad Warren
3349895a3e set up prettier 2025-11-05 10:53:41 -08:00
Brad Warren
30b517069a bundle cli-installer 2025-11-05 10:53:41 -08:00
Brad Warren
4efc2d5db3 npm run tauri add shell 2025-11-05 10:53:41 -08:00
Brad Warren
5e066682b3 run npm run tauri icon & exclude mobile icons 2025-11-05 10:53:41 -08:00
Brad Warren
01aefe25c9 update Cargo.toml and run npm run tauri dev 2025-11-05 10:53:41 -08:00
Brad Warren
e8e9f9366c clean up tauri boilerplate 2025-11-05 10:53:41 -08:00
Brad Warren
fa346989e6 run npm install
we need to track package-lock.json to keep the tauri JS packages in sync
with tauri's rust packages in cargo.lock
2025-11-05 10:53:41 -08:00
Brad Warren
d942545ac3 run create-tauri-app
command was: sh <(curl https://create.tauri.app/sh)

the chosen options were:

Project name · installer-gui
Identifier · com.rayhunter-installer.app
Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, deno, bun)
Choose your package manager · npm
Choose your UI template · Svelte - (https://svelte.dev/)
Choose your UI flavor · TypeScript
2025-11-05 10:53:41 -08:00
Cooper Quintin
e162070a04 bump to version 0.8.0 2025-11-04 12:41:01 -08:00
Markus Unterwaditzer
2e42750b09 Spawn in background, just in case 2025-11-04 11:24:21 -08:00
Markus Unterwaditzer
e375e4587a change openPort to be non-overlapping 2025-11-04 11:24:21 -08:00
Markus Unterwaditzer
2a30e2d709 fix ci to build rootshell if installer changed 2025-11-04 11:24:21 -08:00
Markus Unterwaditzer
fe2b8b3456 TP-Link: Space out HTTP requests a bit, retry connection for sending files
On firmware M7350(EU)_V9_9.0.2 Build 241021 (but not sooner), entryId=2
was being sent before entryId=1. entryId=2 is invalid if entryId=1 does
not exist yet. The reason it works is due to both requests firing
simultaneously, so sometimes entryId=1 is indeed being registered first.

We may also be hitting random race conditions on the backend, not 100%
sure. Try to alleviate them by sleeping 1 second between started
requests and waiting until the DOM is ready.

Also, on sluggish devices, it can happen that nc is not ready within
100ms. Fixing that with exponential backoff.
2025-11-04 11:24:21 -08:00
Markus Unterwaditzer
cedfe2d4d7 Update doc/installing-from-release.md
Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>
2025-11-04 11:22:43 -08:00
Markus Unterwaditzer
22be337f62 rephrase warning msg 2025-11-04 11:22:43 -08:00
Markus Unterwaditzer
6326c5e783 remove old network installer docs 2025-11-04 11:22:43 -08:00
Markus Unterwaditzer
ea5aa6cee2 Make orbic-network the default, update documentation, add deprecation warnings 2025-11-04 11:22:43 -08:00
Markus Unterwaditzer
65d4f22e09 correct port in documentation 2025-11-04 11:22:43 -08:00
Markus Unterwaditzer
450434b4f9 update a broken link 2025-11-03 14:56:59 -08:00
Markus Unterwaditzer
4e93e03e6a Link to CONTRIBUTING.md from issue templates. 2025-11-03 14:56:59 -08:00
Hal Mueller
e416d6e311 Doc spelling and punctuation corrections:
- standardize on American English spellings (behavior, analyze)
- consistent bullet line end punctuation.
- wifi -> WiFi, usb -> USB where needed.
- some comma and article edits.
2025-10-27 01:04:41 +01:00
Hal Mueller
0eebe890c1 Noisey -> noisy 2025-10-27 01:04:41 +01:00
Hal Mueller
28c9f44f73 orcas are whales, not fish 2025-10-27 01:04:41 +01:00
dependabot[bot]
85fa73ddd6 Bump vite from 7.1.9 to 7.1.11 in /daemon/web (#666)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 13:13:23 +02:00
bsickler
b8b90268b9 Enhancement: Remove unnecessary component renders for difference screen sizes (#661)
* adds breakpoint stores to allow selective rendering on screen size

* removes unused type

* formatting
2025-10-19 04:43:15 +02:00
Adrien Thebo
9e5de4a445 check: add flag help documentation (#662) 2025-10-19 04:09:33 +02:00
Adrien Thebo
643fb802be make: install node deps when node_modules absent (#660)
* Install node deps via make.sh when absent

* Unconditionally install node modules
2025-10-19 01:24:44 +02:00
esp323277
93f22172cc Added HTTP GET request to check if uz801 device is online (#648)
Co-authored-by: Your Name <you@example.com>
Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
2025-10-16 18:54:32 +02:00
John DeGood
d5f2dd9813 update uninstall filenames (#646) 2025-10-13 16:54:04 +02:00
Cooper Quintin
d413a76b30 bump version 0.7.1 2025-10-08 13:13:50 -07:00
Markus Unterwaditzer
fc532682df fix some type inference issues by installing @types/node
ProxyServer (first arg in vite.config.ts configure) inherits from
EventEmitter which has no type definition, so on() is not defined.
2025-10-08 10:51:51 -07:00
dependabot[bot]
8569a88f86 Bump esbuild, @sveltejs/vite-plugin-svelte, vite and vitest
Bumps [esbuild](https://github.com/evanw/esbuild) to 0.25.10 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@sveltejs/vite-plugin-svelte](https://github.com/sveltejs/vite-plugin-svelte/tree/HEAD/packages/vite-plugin-svelte), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `esbuild` from 0.21.5 to 0.25.10
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.10)

Updates `@sveltejs/vite-plugin-svelte` from 4.0.4 to 6.2.1
- [Release notes](https://github.com/sveltejs/vite-plugin-svelte/releases)
- [Changelog](https://github.com/sveltejs/vite-plugin-svelte/blob/main/packages/vite-plugin-svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/vite-plugin-svelte/commits/@sveltejs/vite-plugin-svelte@6.2.1/packages/vite-plugin-svelte)

Updates `vite` from 5.4.20 to 7.1.9
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.9/packages/vite)

Updates `vitest` from 2.1.9 to 3.2.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.4/packages/vitest)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.25.10
  dependency-type: indirect
- dependency-name: "@sveltejs/vite-plugin-svelte"
  dependency-version: 6.2.1
  dependency-type: direct:development
- dependency-name: vite
  dependency-version: 7.1.9
  dependency-type: direct:development
- dependency-name: vitest
  dependency-version: 3.2.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-08 10:51:51 -07:00
Cooper Quintin
e60035f744 use port 24 2025-10-08 10:46:41 -07:00
Markus Unterwaditzer
1a80a0576c Add --admin-password to orbic-network installer, update docs
Also add some more debug-logging to telnet_send_file since it appears
to be janky on my device.

see #599
2025-10-08 10:46:41 -07:00
Markus Unterwaditzer
fa5c2bf5d1 Fix installation from source again
Fix https://github.com/EFForg/rayhunter/issues/630

A better fix would be to automatically infer the right path based on
what's available. Maybe based on cfg!(debug_assertions)? idk too fancy.
2025-10-08 09:25:17 -07:00
Markus Unterwaditzer
ce8cbb743f Add TP-Link M7350 bands 2025-10-07 16:59:59 -07:00
Markus Unterwaditzer
13c1602f76 Check in package-lock.json
It seems we never checked in our package-lock.json, which means there's
no effective checksum verification or version pinning going on.
2025-10-07 16:59:08 -07:00
Markus Unterwaditzer
e2cde3be90 Update CONTRIBUTING.md
Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>
2025-10-06 10:40:54 -07:00
Markus Unterwaditzer
8ed3459349 Add CONTRIBUTING.md 2025-10-06 10:40:54 -07:00
Evan Anderson
5ccdcc8685 tplink: Implement battery level support 2025-10-01 10:10:46 -07:00
Markus Unterwaditzer
dac838eea9 Improve log message 2025-10-01 09:57:02 -07:00
Markus Unterwaditzer
9d33c161b6 Replace wrong make.sh command 2025-10-01 09:57:02 -07:00
Markus Unterwaditzer
f6ff61f26b CI: Fix missing components in rust setup 2025-10-01 09:57:02 -07:00
Markus Unterwaditzer
9f57edd385 Fix support for M7350 v6 2025-10-01 09:57:02 -07:00
Cooper Quintin
69260d21ac bump version to 0.7.0 2025-09-24 11:17:39 -07:00
Cooper Quintin
f65e5708fc downgarde incomplete sib heuristic to informational 2025-09-24 11:07:48 -07:00
Brad Warren
6eba455e42 suggest using different usb to troubleshoot 2025-09-24 10:52:13 -07:00
Markus Unterwaditzer
dd0b8050b8 Clarify documentation around TLS backends 2025-09-23 10:05:05 -07:00
Markus Unterwaditzer
6009123649 try to simplify workflows 2025-09-23 10:05:05 -07:00
Markus Unterwaditzer
549d3a6a8f Avoid rustcrypto backend for release builds 2025-09-23 10:05:05 -07:00
Markus Unterwaditzer
3dc807fc63 Do not print instructions for admin UI when telnet is already running 2025-09-22 12:33:38 -07:00
Markus Unterwaditzer
95fe938eeb Refactor and comment JS code 2025-09-22 12:33:38 -07:00
Markus Unterwaditzer
3ada0fa259 fix CI: Daemon should build if installer changed 2025-09-22 12:33:38 -07:00
Markus Unterwaditzer
48a4b43a39 Attempt to fix TP-Link race condition 2025-09-22 12:33:38 -07:00
Simon Fondrie-Teitler
f3c34ce0d3 Fix issue where low battery alert is fired on reboot when batter is 10% 2025-09-22 12:31:57 -07:00
Simon Fondrie-Teitler
1b5575e5a6 Update ConfigForm.svelte
Signed-off-by: Simon Fondrie-Teitler <simonft@riseup.net>
2025-09-22 12:31:57 -07:00
Evan Anderson
1cf6f5d339 installer: Fix installation in skip-sdcard case (#604) 2025-09-19 18:40:18 +02:00
Simon Fondrie-Teitler
b00f17d8fc Use a cancellation token for restart logic as well (#602) 2025-09-18 10:00:07 +02:00
Simon Fondrie-Teitler
766f3461d3 Simplify shutdown with cancellation tokens (#601) 2025-09-18 00:33:44 +02:00
Simon Fondrie-Teitler
d30dd6fd9d Don't show scroll bars with no overflow 2025-09-17 11:38:53 -07:00
bsickler
10e76e351e Maintenance: Removes unused PNG (#598) 2025-09-14 00:09:46 +02:00
Burton Sickler
301d130cdd adds filter to incoming events 2025-09-12 11:34:28 +02:00
Hu8r1z
7a602b577d Update AnalysisTable.svelte
corrected ususally to usually
2025-09-12 11:24:19 +02:00
Tom Plant
f52c673b25 Fix several typos in docs
Ran `typos doc --write-changes` from https://github.com/crate-ci/typos and reviewed
2025-09-10 10:44:53 +02:00
Matthew Callis
e6b9624a34 Fix typo in heuristics.md phoen ➜ phone
Fix typo in `doc/heuristics.md` `phoen` ➜ `phone`
2025-09-09 20:08:11 +02:00
Cooper Quintin
15c0ba3805 cargo fmt 2025-09-05 13:22:42 -07:00
Cooper Quintin
de4a622c68 decode with correct byte order. Fixes #562 2025-09-05 13:22:42 -07:00
Cooper Quintin
a582715177 handle 2 digit MNC. Fixes #580 2025-09-05 13:22:42 -07:00
Markus Unterwaditzer
e68ba6ba52 Always run all builds with -p
Running without -p can confuse cargo to enable the wrong set of
featureflags.

Fix #581
2025-09-03 00:19:17 +02:00
Brad Warren
e216043a14 make analyze_information_element private 2025-09-02 15:18:33 -07:00
Brad Warren
e2bc3a0a67 append packet num in harness & fix packet count 2025-09-02 15:18:33 -07:00
Brad Warren
87d6d1691a track packet num in analysis harness 2025-09-02 15:18:33 -07:00
Sashanoraa
7475cd5cd9 Hide error count in error toast if there is only 1 error 2025-08-28 16:20:16 -07:00
Sashanoraa
cef94ba6b0 Add log view modal to web UI 2025-08-28 16:19:17 -07:00
Sashanoraa
d7c973ea95 Add api for getting rayhunter's log file 2025-08-28 16:19:17 -07:00
Simon Fondrie-Teitler
64d657efd6 Don't return an error if there's an issue parsing battery status 2025-08-28 16:09:09 -07:00
Simon Fondrie-Teitler
16447ed8bf Use percentage battery in frontend and not levels 2025-08-28 16:09:09 -07:00
Simon Fondrie-Teitler
663d0abb57 Add battery level to web UI 2025-08-28 16:09:09 -07:00
Simon Fondrie-Teitler
f49d11f034 Skip updating web UI when browser tab isn't visible
This will save bandwith and battery on the hotspot in cases where the UI
tab is left open. See #564.
2025-08-27 22:52:34 +02:00
Cooper Quintin
56dcfdb47c Update orbic.md 2025-08-26 10:17:54 -07:00
Markus Unterwaditzer
a46ede37b6 Fix links in README 2025-08-26 09:22:07 -07:00
Cooper Quintin
69dc528f34 bump version to 0.6.1 2025-08-25 22:28:10 +02:00
Cooper Quintin
29ce6729ee fix readme links 2025-08-25 11:23:34 -07:00
Cooper Quintin
5919a19aba revert removing link 2025-08-25 09:29:28 -07:00
Cooper Quintin
35ca590e46 improvements to documentation 2025-08-25 09:29:28 -07:00
Sashanoraa
56122f6559 Add error reporting to the daemon web UI
This error reporting comes in two forms:
- Errors updating the UI
- Errors with user actions

The former is displayed as one error until a refresh succeeds again. The
latter creates an number of persistent errors until they are cleared by
the user.
2025-08-25 03:15:25 -04:00
Cooper Quintin
bbab29ae0b Update lib/src/analysis/imsi_requested.rs
Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
2025-08-22 15:37:18 -07:00
Cooper Quintin
2a620fd1fb cargo fmt 2025-08-22 15:37:18 -07:00
Cooper Quintin
515bb40a76 fix false positive on identity requested without attach described in #557 2025-08-22 15:37:18 -07:00
Cooper Quintin
a5ec1c9505 more verbosity 2025-08-22 23:55:40 +02:00
Markus Unterwaditzer
806bd62a0e Document reanalyzing feature, and include rayhunter-check in release 2025-08-22 23:55:40 +02:00
Cooper Quintin
6ceced2d31 bump version to 0.6.0 2025-08-20 17:58:45 -07:00
Cooper Quintin
856374c05a revert framerate change 2025-08-20 17:11:04 -07:00
Cooper Quintin
983867c2a6 make web ui reflect screen ui colors 2025-08-20 17:11:04 -07:00
Cooper Quintin
145d0a295a cargo fmt 2025-08-20 17:11:04 -07:00
Cooper Quintin
c021b9150d slight refactor 2025-08-20 17:11:04 -07:00
Cooper Quintin
ce916dcd10 display colors as well as patterns for severity 2025-08-20 17:11:04 -07:00
Cooper Quintin
898bdbb6cd include event severity in message 2025-08-20 17:11:04 -07:00
Markus Unterwaditzer
375789aad9 cargo fmt 2025-08-20 17:11:04 -07:00
Cooper Quintin
85f7b2cc81 refactor test analyzer for new format 2025-08-20 17:11:04 -07:00
Markus Unterwaditzer
781d11ed72 Expose severity to display
See https://github.com/EFForg/rayhunter/issues/334

Severity levels low, medium, high are now exposed to the UI in form of
dotted, dashed and solid lines. The line on the UI represents the
highest-so-far severity seen.

Originally this was intended to be represented by Yellow/Orange/Red, but
this would mean yet another divergence for colorblind mode. This is
colorblind-friendly by default (I think...)

As part of this, simplify EventType so that it becomes a flat "level"
enum without nested variants.

There is also a new debug endpoint that allows one to overwrite the
display level directly for testing.
2025-08-20 17:11:04 -07:00
Cooper Quintin
6927da49b4 cargo fmt 2025-08-20 14:48:47 -07:00
Cooper Quintin
479505f738 appease clippy 2025-08-20 14:48:47 -07:00
Cooper Quintin
468b07faf0 proper formatting for CID and PLMN 2025-08-20 14:48:47 -07:00
Cooper Quintin
493fdfa227 add docs 2025-08-20 14:48:47 -07:00
Cooper Quintin
ffdad4aed8 add test analyzer 2025-08-20 14:48:47 -07:00
Markus Unterwaditzer
33e4fbc544 Fix wrong analyzer name in AnalysisTable
Each event index corresponds to an index in analyzers. But some events
may be null. We're skipping those events without incrementing the index,
leading to wrong analyzer names.
2025-08-20 14:39:09 -07:00
Markus Unterwaditzer
8c510b43c9 Merge pull request #429 from simonft/notifications 2025-08-20 21:56:45 +02:00
Markus Unterwaditzer
46850e2739 Update installer/src/tplink.rs
Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>
2025-08-20 09:53:10 -07:00
Markus Unterwaditzer
53e3b8ee34 Mention required SD card for tplink 2025-08-20 09:53:10 -07:00
Markus Unterwaditzer
0fc51d79f4 Fix issue template and improve one error message
The current error message is not very useful for remote-debugging. Add
enough context to allow technically adept users to figure out a way.

See #544
2025-08-20 09:53:10 -07:00
Markus Unterwaditzer
ad4e971e77 Restyle download links in table view
They're taking up too much space as individual columns.

Also change DownloadLink to be a real link, so that "Copy URL" works.
2025-08-20 09:48:37 -07:00
Markus Unterwaditzer
c5a79e545d Add documentation for Orbic Network installer 2025-08-20 09:41:43 -07:00
Markus Unterwaditzer
9d92ab3c01 Simplify remounting procedure 2025-08-19 17:36:10 -07:00
Markus Unterwaditzer
cf254b66ff Address review comments and update wingtech docs 2025-08-19 17:36:10 -07:00
Markus Unterwaditzer
cddc590c77 Try to use the same rust version as in other jobs 2025-08-19 17:36:10 -07:00
Markus Unterwaditzer
9d736f5bf0 Add a orbic network installer
There is a shell injection vulnerability after all, so we can just
launch a remote shell, tplink-style. Except there's no telnetd on this
device so we need to use netcat.

This was found in the goahead binary on the device using Ghidra. The
decompiled code for this endpoint looks like this:

```c
void FUN_0003c614(int param_1)

{
  int iVar1;
  undefined4 uVar2;
  int local_160;
  undefined1 auStack_15c [64];
  char acStack_11c [256];
  int local_1c;

  local_1c = __stack_chk_guard;
  if (param_1 == 0) {
    error("input parameter is NULL!");
    uVar2 = 0x66;
    goto LAB_0003c808;
  }
  iVar1 = websGetJsonItemValue(param_1,"password",10,auStack_15c,0x40);
  if (iVar1 != 0) {
    iVar1 = get_log_level_something();
    if (1 < iVar1) {
      some_logging_func(2,"modifying root password(%s)...",auStack_15c);
    }
    iVar1 = sprintf(acStack_11c,"echo root:\"%s\"|chpasswd",auStack_15c);
    acStack_11c[iVar1] = '\0';
    system(acStack_11c);
  }
```

Usage is `./installer orbic-network`, as an alternative to `./installer
orbic`. It should work on Windows without any kind of drivers.

This installer also works on the Moxee device.
2025-08-19 17:36:10 -07:00
oopsbagel
e5df43d7f5 Merge pull request #520 from simonft/update-install-commands-in-doc
Update instructions for installing from source
2025-08-13 20:22:33 +00:00
Simon Fondrie-Teitler
a8667cc3a0 Pass notification_channel in diag_task constructor 2025-08-11 13:59:42 -04:00
Markus Unterwaditzer
3239daa011 Merge pull request #529 from EFForg/macos-intel-ci
Move MacOS intel builds to macos-latest
2025-08-11 16:50:14 +02:00
Markus Unterwaditzer
651511cc63 Merge pull request #530 from EFForg/windows-installer
Rename windows installer file to installer.exe
2025-08-11 16:50:02 +02:00
Simon Fondrie-Teitler
211066ec7b Record last attempt time when notification connection fails 2025-08-09 15:18:04 -04:00
Simon Fondrie-Teitler
16ec9e28df Remove a couple unnecssary whitespace changes 2025-08-09 15:03:08 -04:00
Simon Fondrie-Teitler
4462f02c10 Properly close notification service channel 2025-08-09 14:55:13 -04:00
Simon Fondrie-Teitler
5bd2d9a58e Merge branch 'main' into notifications 2025-08-09 14:17:22 -04:00
Markus Unterwaditzer
603d65a3bd Move MacOS intel builds to macos-latest
We're now using ARM runners which crosscompile to Intel.

Fix #525
2025-08-08 21:53:20 +02:00
Markus Unterwaditzer
c0a9cf62df Rename windows installer file to installer.exe
Fix #521
2025-08-08 21:53:02 +02:00
Markus Unterwaditzer
0a20e659be Merge pull request #527 from EFForg/fix-rust-warnings
Fix clippy lints and warnings in Rust 1.89
2025-08-08 21:52:46 +02:00
Markus Unterwaditzer
ce599dc432 specify package.rust-version for daemon 2025-08-08 18:02:40 +02:00
Markus Unterwaditzer
85b50bc301 Remove unpack! macro 2025-08-08 03:12:38 +02:00
Markus Unterwaditzer
5249714717 Fix clippy lints and warnings in Rust 1.89
This will also require Rust 1.89 due to if-let.
2025-08-08 03:02:07 +02:00
Simon Fondrie-Teitler
67974264f9 Update Config object in svelte to add ntfy url 2025-08-06 13:12:10 -04:00
Simon Fondrie-Teitler
f562d33be3 Document notification configuration 2025-08-06 13:08:16 -04:00
Simon Fondrie-Teitler
0531aa0e3a Require rust 1.88 so we can use let chains 2025-08-06 13:08:08 -04:00
Simon Fondrie-Teitler
dd78f5007d Specify ntfy notification is from rayhunter 2025-08-06 13:07:25 -04:00
Simon Fondrie-Teitler
1c08708bc4 Set full ntfy url in config instead of just topic 2025-08-06 13:07:08 -04:00
Simon Fondrie-Teitler
0f53da58bc Better document options when installing from source 2025-08-06 10:55:05 -04:00
Simon Fondrie-Teitler
01010df4ec Update instructions for installing from source
Command changed in #460
2025-08-05 17:59:13 -04:00
Simon Fondrie-Teitler
481f02f81f Merge branch 'main' into notifications 2025-08-05 17:30:07 -04:00
Markus Unterwaditzer
8c67a92b07 Merge pull request #511 from Tunas1337/uz801
Add UZ801 support
2025-08-05 21:23:27 +02:00
Markus Unterwaditzer
31bd60dea1 cargo fmt 2025-08-05 21:11:17 +02:00
Markus Unterwaditzer
13877f7209 cargo clippy 2025-08-05 21:05:31 +02:00
Andrej
f4522dbe3d cargo fmt run 2025-08-05 14:21:19 -04:00
Matej Kovacic
30bb18016e Update heuristics.md 2025-08-05 08:17:51 -07:00
Matej Kovacic
c6aa53acd2 Update heuristics.md
More explanation of logic behind analysers.
2025-08-05 08:17:51 -07:00
oopsbagel
c6882ed173 Merge pull request #515 from EFForg/installer-faster
Speed up file transfer for network-based installers
2025-08-05 00:30:46 +00:00
Markus Unterwaditzer
5c03f6ea03 Add reanalyze button
Add a reanalyze button for individual recordings in the analysis dropdown

As part of this, split out ApiRequestButton so that state transitions
(clickable -> loading/disabled -> done) can be shared across start/stop
recording and this new button. Other buttons might benefit from this as
well.

Also fix a broken checkbox while we're here.
2025-08-04 17:19:42 -07:00
Andrej
5184c6138d uz801: Add uninstalling instructions 2025-08-04 13:23:14 -04:00
Andrej
c893f8e2a9 uz801: update docs 2025-08-04 13:16:46 -04:00
Andrej
2e6343c343 uz801: clippy fixes 2025-08-04 11:35:23 -04:00
Andrej
da4a86be13 uz801: Installer improvements 2025-08-04 11:33:37 -04:00
Andrej
55794cbdd5 uz801: Fix clippy warnings 2025-08-03 20:03:01 -04:00
Andrej
e36b490d15 uz801: installer: Add file transfer verify, other fixes 2025-08-03 19:27:25 -04:00
Andrej
574e897610 cargo fmt pass 2025-08-03 18:49:38 -04:00
Andrej
1f19bc880f uz801: Force LED updates every 5s to beat MifiService 2025-08-03 18:35:54 -04:00
Markus Unterwaditzer
8dc6206683 Speed up file transfer for network-based installers
There is a bug in `telnet_send_file` where we never close the connection
to nc, and instead wait for it to time out.

This means every file transfer takes at least 5 seconds.
2025-08-04 00:06:57 +02:00
Andrej
7184ccd5c1 uz801: installer: Update VID/PID, add remount 2025-08-03 18:06:13 -04:00
Andrej
cb22e179d6 uz801: installer: Add missing dependency 2025-08-03 17:30:15 -04:00
Andrej
a3db5029ad uz801: Update installer 2025-08-03 16:51:46 -04:00
Andrej
9f661ab398 uz801: Update documentation 2025-08-03 16:51:36 -04:00
Andrej
412ad3d8bf cargo fmt run 2025-08-03 16:29:59 -04:00
Andrej
4d2d49326a uz801: Update installer usb backdoor trigger code 2025-08-03 11:12:46 -04:00
Andrej
c26ad29ffb uz801: Fix installer defaults, fix docs for admin IP 2025-08-03 11:05:50 -04:00
Andrej
f57fc611c2 uz801: Use busybox coreutils (they were hiding all along) 2025-08-03 10:54:47 -04:00
Andrej
38a408757a Add uz801 to supported devices list 2025-08-03 10:28:43 -04:00
Andrej
0540504eea uz801: Correct LED definition comment 2025-08-03 10:25:56 -04:00
Andrej
28a0c06017 uz801: Add purchase links to documentation 2025-08-03 00:51:35 -04:00
Andrej
6141087f9d uz801: Added docs 2025-08-03 00:41:40 -04:00
Andrej
7a053a4f89 uz801: cargo fmt run 2025-08-02 21:03:28 -04:00
Andrej
6473c05e3e uz801: Refactor strings, since &&'static str can be coerced into &dyn AsRef<str>: & 2025-08-02 20:59:09 -04:00
Andrej
c697773244 uz801: Add initial (experimental) support 2025-08-02 20:58:19 -04:00
oopsbagel
fe6afac817 Merge pull request #499 from EFForg/installer-issue-tmpl
Add an issue template for Installer issues
2025-08-02 18:50:13 +00:00
oopsbagel
8e708f145e doc/pinephone: the installer runs on the phone 2025-08-01 09:42:34 -07:00
oopsbagel
03c00a1f19 installer/orbic: warn windows users this may brick
The windows installer seems to sometimes brick the Orbic's ARM core,
resulting in the DSP returning "Qmi Send Message Fail" when sent AT
commands.

This commit adds a loud warning and confirmation dialog for Windows
users before installing.
2025-07-31 22:22:55 -07:00
oopsbagel
64842c7140 release v0.5.1 2025-07-31 22:22:55 -07:00
Markus Unterwaditzer
e108c21fc2 Use ./installer in docs
See https://github.com/EFForg/rayhunter/discussions/490
2025-07-31 20:55:41 +02:00
Sashanoraa
49a2108214 Add an issue template for Installer issues 2025-07-31 14:42:17 -04:00
Markus Unterwaditzer
53a6cbe95a Fix line endings on Windows
Fix #489
2025-07-31 18:06:52 +02:00
Sashanoraa
398997af67 Refactor diag thread to have full control over the QMDL store
Fixes #269. Refactor also pull diag thread logic out into state machine
object for better encapsulation and reuse.
2025-07-31 11:47:11 +02:00
oopsbagel
6b109a9d76 Merge pull request #498 from oopsbagel/wingtech-wifi-install-fix
wingtech: install without disabling wifi
2025-07-31 03:29:53 +00:00
oopsbagel
d9688b1796 wingtech: install without disabling wifi
Previously, the unlocking method for the wingtech hotspot would add a
invalid mac address to the blocklist. This would prevent the wifi from
coming online after rebooting until the invalid mac was removed.

This commit changes the unlocking method to attempt to *remove* an
invalid mac, creating a no-op condition that still works for unlocking
root access to the hotspot.

This commit also adds documentation for a problem where the hotspot
would occasionally not reboot while completely disconnected and
installing over wifi.

Fixes #466
2025-07-30 20:09:26 -07:00
Sashanoraa
7466c1c669 Fixes #381 UI no longer X overflows on mobile
Button will horizontally shrink a little on smaller screens and buttons
and tables will X scroll if needed.
2025-07-30 14:13:16 -04:00
Cooper Quintin
6a51050921 Update doc/configuration.md
Co-authored-by: Will Greenberg <ifnspifn@gmail.com>
2025-07-28 16:05:58 -07:00
Matej Kovacic
0935cf8239 Delete doc/Rayhunter_0.3.2.png 2025-07-28 16:05:58 -07:00
Matej Kovacic
d25e9588e2 Update heuristics.md 2025-07-28 16:05:58 -07:00
Matej Kovacic
a8ff95a07b Update using-rayhunter.md 2025-07-28 16:05:58 -07:00
Matej Kovacic
ac86277903 Add files via upload 2025-07-28 16:05:58 -07:00
Matej Kovacic
8e9abc718a Update configuration.md 2025-07-28 16:05:58 -07:00
Matej Kovacic
d92fb16c57 Add files via upload 2025-07-28 16:05:58 -07:00
Markus Unterwaditzer
f8824ce7e7 Remove blocking code and spawn_blocking
Rayhunter uses a mixture of spawn and spawn_blocking, then also does
some blocking operations inside of async code.

Move everything to async. This allows us to use the single-threaded
runtime.

Now the binary is 100kB smaller, and the memory usage also improved by
~100kB on tplink.
2025-07-28 16:04:24 -07:00
Sashanoraa
9694aa826b Use firmware-devel profile for make.sh 2025-07-28 11:09:48 -07:00
Sashanoraa
b859dde0c8 Add firmware-devel profile to cargo config
This speeds up compile time in exchange for binary size,
which is often a worthy trade-off in development when iteration speed
matters.
2025-07-28 11:09:48 -07:00
oopsbagel
5b6a73bc44 Merge pull request #477 from EFForg/release-0.5.0
Bump version to 0.5.0
2025-07-25 20:11:00 +00:00
Will Greenberg
8cbdbf5ebe Bump version to 0.5.0 2025-07-25 12:58:48 -07:00
Cooper Quintin
ccce63e90c address comments 2025-07-24 16:44:59 -07:00
Cooper Quintin
68b13ea09e Update lib/src/analysis/incomplete_sib.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2025-07-24 16:44:59 -07:00
Cooper Quintin
672d825bdb rm stray char 2025-07-24 16:44:59 -07:00
Cooper Quintin
fd216ecb72 add incomplete sib heuristic 2025-07-24 16:44:59 -07:00
Cooper Quintin
07d43b5924 remove unused heuristic 2025-07-24 16:44:59 -07:00
Will Greenberg
bd3e439a1d lib: pycrate-rs now lives in EFF's github org 2025-07-24 15:19:33 -07:00
oopsbagel
5491c3f3a0 installer: remove struct NoArgs
An empty struct indicating no arguments is superfluous if the caller
doesn't specify a tuple in its corresponding match arm.
2025-07-24 09:48:41 -07:00
oopsbagel
fa14e4ecfc doc: add pinephone to supported devices 2025-07-24 09:48:41 -07:00
oopsbagel
8583064e46 installer: add pinephone and pinephonepro support 2025-07-24 09:48:41 -07:00
oopsbagel
d3bd8d9dfc feat: add headless display mode 2025-07-24 09:48:41 -07:00
oopsbagel
b16a351727 Merge pull request #471 from oopsbagel/framebuffer-usually-display-status-bar
generic_framebuffer: show status when ui_level!=0
2025-07-24 04:44:26 +00:00
oopsbagel
cd781fe8d8 Merge pull request #472 from oopsbagel/doc-orbic-disable-wifi
doc: add how to disable wifi entirely to the faq
2025-07-24 04:43:59 +00:00
oopsbagel
df00e00076 doc: add how to disable wifi entirely to the faq
Fixes #234

Co-authored-by: joelishness <joelishness@gmail.com>
2025-07-23 16:14:56 -07:00
oopsbagel
1a810cfb33 generic_framebuffer: show status when ui_level!=0
Unless ui_level = 0 (invisible mode), display the status bar on
framebuffer displays even when running in demo mode.

Fixes #445
2025-07-23 15:48:09 -07:00
Sashanoraa
b16b1af65e Fixes #433 do not stop recording when deleting a past entry 2025-07-20 18:07:24 -04:00
Cooper Quintin
a346449ec5 cargo fmt 2025-07-18 11:19:07 -07:00
Cooper Quintin
464740a1a7 fix another false positive 2025-07-18 11:19:07 -07:00
Cooper Quintin
e07b0b05e7 imsi requested version 2; remove most false positives 2025-07-17 18:43:07 -07:00
Cooper Quintin
578bc0d234 modifications to rayhunter-check 2025-07-17 18:43:07 -07:00
oopsbagel
751d504440 rename config.toml.example to config.toml.in
Since we now replace a value in this file, it's more of a template than
an example.
2025-07-17 16:06:09 -07:00
oopsbagel
29c944af45 diag_device: accept rayhunter::Device arg
Move daemon's config::Device to lib and toggle the extra tplink ioctl
based on Device::Tplink rather than passing a boolean.
2025-07-17 16:06:09 -07:00
oopsbagel
e239653a44 config: rename display to device
Because we toggle some ioctl settings based on this field, change the
name to better capture that we're selecting which device we want to load
settings for, not just the display module to load. This creates room for
future per-device settings without needing more config file fields.
2025-07-17 16:06:09 -07:00
oopsbagel
841bc7b015 ci: use unified rayhunter-daemon in main workflow 2025-07-17 16:06:09 -07:00
oopsbagel
22d927aa25 unified rayhunter-daemon binary for all devices
Replace per-device features with config "display" field with the value
set at install time.
2025-07-17 16:06:09 -07:00
Will Greenberg
5b59efa4c8 cargo fmt 2025-07-17 10:41:54 -07:00
Will Greenberg
f273d28728 daemon: fix recovery timestamp 2025-07-17 10:41:54 -07:00
Will Greenberg
f1e283b52c daemon: do a best-effort manifest recovery
Revises @cooperq's recovery a bit by preventing any panics, recovering
whichever files we can and skipping the ones we can't.
2025-07-17 10:41:54 -07:00
Will Greenberg
1011c4b123 daemon: don't track analysis file bytes written
We don't actually use this for anything
2025-07-17 10:41:54 -07:00
Cooper Quintin
5db24e4b21 recreate manifest entries if manifest is recreated due to error fixes #199 2025-07-17 10:41:54 -07:00
oopsbagel
a72e4b2234 docs: minor fixes for <links>, npm from daemon/web 2025-07-16 17:29:38 -07:00
oopsbagel
ca0151f656 chore: cargo fmt 2025-07-16 16:56:16 -07:00
oopsbagel
56930db130 doc(tmohs1): add bands 2, 4, 5
These were originally excluded because they weren't tested, as they are
subsets of bands 25, 26, and 66, but are worth enumerating.
2025-07-16 16:56:16 -07:00
oopsbagel
f018b8f662 ci: build rayhunter-daemon for tmobile 2025-07-16 16:56:16 -07:00
oopsbagel
7e0f12f1c5 doc(tmohs1): one must change the default password 2025-07-16 16:56:16 -07:00
oopsbagel
e32a6f5b2e fix(installer/tmobile): wait 200ms for telnet 2025-07-16 16:56:16 -07:00
oopsbagel
58618f3412 doc: support the Tmobile TMOHS1 hotspot 2025-07-16 16:56:16 -07:00
oopsbagel
003a8b280b chore: cargo fmt 2025-07-16 16:56:16 -07:00
oopsbagel
27bf20fbf4 fix(installer): de-duplicate common wingtech/tmohs
The Tmobile TMOHS1 is clearly based on the Wingtech CT2MHS01, and thus
the installation process looks very similar between them.
2025-07-16 16:56:16 -07:00
oopsbagel
b7636386fc feat: support Tmobile TMOHS1 hotspot
Add support for the Tmobile TMOHS1, a Wingtech CT2MHS01-based hotspot
with a Qualcomm mdm9607. The TMOHS1 has no screen, only 5 LEDs, two of
which are RGB.
2025-07-16 16:56:16 -07:00
Cooper Quintin
f23cc07652 Update doc/heuristics.md
Co-authored-by: Will Greenberg <willg@eff.org>
2025-07-16 15:25:11 -07:00
Cooper Quintin
f9b621bde9 Update lib/src/analysis/nas_null_cipher.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2025-07-16 15:25:11 -07:00
Cooper Quintin
a4cb9454bd add version 2025-07-16 15:25:11 -07:00
Cooper Quintin
fbac464b46 add config option and documentation 2025-07-16 15:25:11 -07:00
Cooper Quintin
b923d9d5a6 cargo fmt 2025-07-16 15:25:11 -07:00
Cooper Quintin
790c0963cd add nas null cipher analyzer 2025-07-16 15:25:11 -07:00
Will Greenberg
32106ac0f4 daemon: rm vestigial dummy_analyzer remnants 2025-07-16 13:20:14 -07:00
Will Greenberg
1ce4d99c59 web: consider unversioned heuristics to be v0
This lets us cleanly differentiate old heuristics (which we know contain
some false positives) from our current set.
2025-07-16 13:20:14 -07:00
Will Greenberg
b055ddc670 web: more linting 2025-07-16 13:20:14 -07:00
Will Greenberg
09d4328dc2 web: lint fixes 2025-07-16 13:20:14 -07:00
Will Greenberg
1a4deb7524 appease clippy 2025-07-16 13:20:14 -07:00
Will Greenberg
0585e0f996 run cargo fmt 2025-07-16 13:20:14 -07:00
Will Greenberg
c783831e78 check: support pcaps
rayhunter-check will now analyze any PCAP files it finds in addition to
QMDL
2025-07-16 13:20:14 -07:00
Will Greenberg
3ddbaa07ca CI: fix paths to daemon workspace 2025-07-16 13:20:14 -07:00
Will Greenberg
83f246e9af lib: bump the analysis report version 2025-07-16 13:20:14 -07:00
Will Greenberg
0d96b4c103 frontend: bump sveltekit version
We're using a config setting that's only present in sveltekit v2.13
onwards
2025-07-16 13:20:14 -07:00
Will Greenberg
7cd8835cab frontend: handle both old and new analysis reports
Adds support for versioned analysis reports (and defaults to v1 for
reports with no version).
2025-07-16 13:20:14 -07:00
Will Greenberg
e81df18315 check: crawl input dir for QMDL, update to new analysis format 2025-07-16 13:20:14 -07:00
Will Greenberg
0915103ede Flattens analysis structure a bit
Instead of mirroring the QMDL container format exactly, let our analysis
files just be flat lists of packet analysis. Also removes the dummy
analyzer and adds version numbers to analysis reports and Analyzers
2025-07-16 13:20:14 -07:00
Will Greenberg
da18a1f9da Split bin dir into separate daemon and check dirs
This lets us manage their increasingly disparate dependencies separately
2025-07-16 13:20:14 -07:00
oopsbagel
5bb3dc9db5 Merge pull request #455 from oopsbagel/ci-release-one-zip-per-platform
ci: release one zip{,.sha256sum} per platform
2025-07-16 14:43:52 +00:00
oopsbagel
c2c6004f4e ci: release one zip{,.sha256sum} per platform 2025-07-16 00:22:49 -07:00
Markus Unterwaditzer
e320874854 Remove PartialEq, Clone, Serialize from LTE RRC parser
Compiling telecom-parser takes 2 minutes on my machine. After removing
those derives it takes 1:15. I suspect it's mostly serde though.
2025-07-15 15:21:02 -07:00
Markus Unterwaditzer
300215206c Inline all assets into index.html, compress it, saves 300kB 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
5e328b889b Fix eslint issues 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
97cbe62f42 Fix all issues in npm run check 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
27408dd64a Add web frontend linter to CI 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
e5c0e13d32 run prettier 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
41133ba793 Changes to web configs 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
0be2b02349 Define missing stop_recording/start_recording_classes
These variables were referenced but not actually defined. Define them
and make the button disabled when rayhunter is stopping/starting
2025-07-10 12:24:26 -07:00
oopsbagel
81eb3eac57 Merge pull request #440 from oopsbagel/tokio-rt-current-thread
installer: tokio runtime flavor current_thread
2025-07-09 23:33:16 +00:00
Markus Unterwaditzer
3247d35b7e Fix clippy lints
https://github.com/EFForg/rayhunter/pull/451 is failing because we got
auto-upgraded to a new clippy, which lints against more things
2025-07-10 01:17:58 +02:00
oopsbagel
355242fa71 Merge pull request #448 from oopsbagel/arm32-installer
ci: build installer and rayhunter-check for armv7, rename ubuntu -> linux in release bundles
2025-07-07 18:34:19 +00:00
oopsbagel
72d6c65f29 ci: use soft float target for armv7
Support more platforms by using a the soft float musl target for
aarch32/armv7/v8. The installer is not performance bound by floating
point operations.
2025-07-06 16:04:17 -07:00
oopsbagel
5e66c26e70 ci: build installer and rayhunter-check for armv7
Support installing on aarch32/armv7 linux systems.
2025-07-04 12:18:12 -07:00
Markus Unterwaditzer
b0d8307a14 Add send-file utilities for wingtech and tplink
Since we never turn on ADB, it's a bit cumbersome to send files to the
device.
2025-07-04 11:21:51 +02:00
oopsbagel
cf0875f2e3 Merge pull request #442 from oopsbagel/release-independent-zips
ci: release platform independent installer zips
2025-07-03 08:33:45 +00:00
oopsbagel
1c51e5ed6f ci: release platform independent installer zips
Release one zip file for each operating system / architecture
combination supported by the installer.
2025-07-03 01:03:29 -07:00
oopsbagel
3a393fc29f installer: tokio runtime flavor current_thread
Slightly reduce binary size by using the smaller 'rt' feature and the
current_thread runtime flavor in the installer, since there is no
benefit to true multithreading.
2025-07-01 22:07:47 -07:00
oopsbagel
b97421d220 Merge pull request #438 from oopsbagel/doc-supported-devices
documentation: Attempt to clarify supported devices
2025-06-29 19:23:50 +00:00
oopsbagel
1bf386d5b7 doc: supported devices: say "recommended region"
Individual device pages can go into detail about specific bands and
roaming. This page is primarily for steering users towards which device
to buy, and we only want to recommend devices that are affordable in
their region.
2025-06-29 12:16:07 -07:00
oopsbagel
8de4dcfd18 doc: add white background to map for dark mode 2025-06-29 12:14:54 -07:00
QuietEngineer
c0b1d4608a docs: standardize rayhunter casing
Use uppercase Rayhunter throughout docs when refering to the project and software.
Does not change pathnames.
2025-06-29 21:02:09 +02:00
QuietEngineer
ee8bf0107a docs: misc typo fixes 2025-06-29 21:02:09 +02:00
oopsbagel
664ffc8c75 doc: move wingtech developer details to the bottom 2025-06-29 02:07:22 -07:00
oopsbagel
d03debe67c doc: add region table, map for recommended devices
Implement reordering from untitaker's suggested changes.
2025-06-29 02:05:58 -07:00
oopsbagel
60922afc87 Merge pull request #436 from oopsbagel/smaller-installer
Smaller installer
2025-06-29 00:34:05 +00:00
oopsbagel
932fef32b9 Merge pull request #421 from EFForg/doc-recommended-devices
doc: Give guidance on which device to get
2025-06-29 00:33:50 +00:00
Markus Unterwaditzer
e259417f35 Revert some of the changes in supported-devices index 2025-06-29 02:07:13 +02:00
Markus Unterwaditzer
3889c89b5a Fix autolinks 2025-06-29 00:51:01 +02:00
Markus Unterwaditzer
bd074066c5 Adjust language around supported versions, to be similar across pages 2025-06-29 00:38:47 +02:00
Markus Unterwaditzer
8b44f604ea sort orbic to the top 2025-06-29 00:38:47 +02:00
Markus Unterwaditzer
ef7b8129ef Fix version number in docs, 0.3.5 does not exist 2025-06-29 00:38:47 +02:00
Markus Unterwaditzer
c3fd724ac1 doc: Give guidance on which device to get 2025-06-29 00:38:47 +02:00
oopsbagel
28ead37111 cargo/config: drop inherited firmware profile opts
These options are shared with the release profile.
2025-06-28 15:25:15 -07:00
oopsbagel
6efe83b36d cargo/config: build release bins with opt-level z
This yields a smaller binary and faster compile times than the default.

cf 5.6M binary in 2m 12s vs. 4.7M in 1m 39s on my machine.
2025-06-28 14:40:12 -07:00
oopsbagel
4d0427fe68 installer: fewer tokio and axum crate features 2025-06-28 05:26:59 -07:00
oopsbagel
1ee35dad71 cargo/config: build release binaries with fat lto
Reduce installer binary size with link-time optimisation.
2025-06-28 05:19:52 -07:00
oopsbagel
5d2a5a2577 Merge pull request #434 from oopsbagel/profile-release-strip-debuginfo
cargo/config: strip debuginfo from --release bins for a significantly smaller installer size
2025-06-28 11:00:28 +00:00
oopsbagel
a4f4e12a57 ci: build everything when .cargo changes 2025-06-28 01:06:56 -07:00
oopsbagel
55178e60fd cargo/config: strip debuginfo from release bins
rustc -C strip=debuginfo leaves the symbol table intact, meaning
RUST_BACKTRACE=1 on the installer still produces helpful output.

This significantly reduces the binary size, eg the amd64 installer goes
from 93M to 21M. Stripping the symbol table only reclaims a further ~2M.
2025-06-28 01:06:51 -07:00
Simon Fondrie-Teitler
5019f2a9d1 Bump Rust edition to 2024
Includes new cargo fmt changes
2025-06-28 00:13:15 +02:00
Simon Fondrie-Teitler
f55d9128d4 Merge branch 'main' into notifications 2025-06-27 17:25:01 -04:00
Simon Fondrie-Teitler
25978a4da4 Fix tplink clippy warnings 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
4ad79707bb Add two too_many_arguments exceptions 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
5f45ae31d8 Fail build on cargo check and clippy warnings 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
ed3072eb8e also run formatter 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
94289dcad5 Fix clippy complaints 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
84534bbb2c Clippy fixes 2025-06-27 13:56:30 -04:00
Simon Fondrie-Teitler
1d50440c85 Use RustCrypto TLS provider so we don't need to depend on LLVM 2025-06-27 11:44:35 -04:00
Simon Fondrie-Teitler
2c05f3d94e Add notifications with ntfy 2025-06-26 18:01:29 -04:00
Markus Unterwaditzer
2b86691e57 Bump hampi to 0.7.1 (#423)
It's not clear for how long this revision will exist upstream now that
the PR is (squash) merged
2025-06-25 09:59:39 +02:00
oopsbagel
0a15ca1b1a Merge pull request #420 from oopsbagel/wingtech-docs
doc: Wingtech CT2MHS01
2025-06-25 02:44:54 +00:00
oopsbagel
eeef42f4cb doc(wingtech): ./installer not ./install 2025-06-24 17:08:39 -07:00
oopsbagel
04cf0ab73a doc(Wingtech): device tiers, wt installation
Documents:
- supported device tiers
- wingtech installation
- wingtech shell unlocking
- wingtech rayhunter supported version
- links to wingtech fcc emissions lte test report
2025-06-24 16:23:04 -07:00
Cooper Quintin
23a0f72c2f cargo fmt 2025-06-24 12:52:33 -07:00
Will Greenberg
efae6203a9 rm unneeded comment 2025-06-24 12:52:33 -07:00
Will Greenberg
2e4de4a2df lib: Use pycrate-rs NAS parser 2025-06-24 12:52:33 -07:00
Will Greenberg
deeab1f1b0 Fix make script
Only build the daemon for make's purposes
2025-06-24 10:30:49 -07:00
Will Greenberg
83dba77cba Bump version to 0.4.0 2025-06-24 10:30:49 -07:00
Markus Unterwaditzer
542aff4fdf Merge pull request #351 from untitaker/restart
Config change and restart UI
2025-06-24 19:23:30 +02:00
oopsbagel
aac0c34eaa doc: Wingtech CT2MHS01 2025-06-24 01:10:50 -07:00
Markus Unterwaditzer
2ececf9c58 Fix tests 2025-06-24 02:09:22 +02:00
Markus Unterwaditzer
2cba26a4cc Remove unused import 2025-06-24 01:25:01 +02:00
Markus Unterwaditzer
48c0592b18 Merge remote-tracking branch 'origin/main' into restart 2025-06-24 01:24:43 +02:00
Markus Unterwaditzer
a21c9af354 Restart when config is set 2025-06-24 01:12:46 +02:00
Markus Unterwaditzer
0c241aba23 Address review comments 2025-06-23 14:30:40 -07:00
Markus Unterwaditzer
b2502847a1 Add ZIP download endpoint 2025-06-23 14:30:40 -07:00
Markus Unterwaditzer
be6f29dcf1 Add exponential backoff 2025-06-23 22:47:34 +02:00
Markus Unterwaditzer
2114206909 Remove advanced options 2025-06-23 21:24:04 +02:00
Markus Unterwaditzer
f735f033d3 Fix indentation 2025-06-23 21:13:41 +02:00
Markus Unterwaditzer
b825174a07 Apply suggestions from code review
Co-authored-by: Will Greenberg <ifnspifn@gmail.com>
2025-06-23 21:03:01 +02:00
Markus Unterwaditzer
29823d3e82 Update documentation and fix broken links
* Add a new configuration page and move content out from TP-Link. The
  Configuration section in TP-Link is duplicating what is already in
  config.toml.example, and given that we have recently added a lot of
  new options I don't want to maintain multiple copies.

* Lots of anchor links were broken since we moved docs from README into
  mdbook. Fix them all.

* Document that the key input feature is disabled since 0.4.0.

* Smaller cosmetic changes:

  * Make TP-Link M7350 page consistent with TP-Link M7310 page.
  * Fix indentation on some bullet points.
  * Center-align the rayhunter logo in introduction.md to calm my soul.
    It is still misaligned with the page title above itself.
  * Add "edit on github" link in mdbook settings.
2025-06-23 09:40:20 -07:00
Markus Unterwaditzer
e52d382514 Make SvelteKit build reproducible 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer
a17e255148 un-skip jobs for testing 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer
0f98b05475 Try out rust-cache action 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer
5e5514a11f Fix key_input blocking shutdown
Discovered in #351 where restart would hang forever.

key_input.rs never properly implemented shutdown because it didn't have
to do anything interesting on shutdown.

Wire up oneshot channels so that it falls in line with other services.

I do wonder though if there's a more clever way of handling this. For
example I could just not use the task_tracker, use tokio::spawn and let
the task get cancelled by tokio.
2025-06-21 23:57:54 +02:00
Markus Unterwaditzer
9904b74d21 basic ui 2025-06-20 11:47:40 +02:00
Markus Unterwaditzer
d166dfc13d get config and set config 2025-06-20 11:47:40 +02:00
Markus Unterwaditzer
9b759e6b42 add basic restart endpoint 2025-06-20 11:47:40 +02:00
oopsbagel
5614c725a0 Merge pull request #418 from EFForg/tplink-hotfix-v9
Fix broken installer on TP-Link M7350 v9
2025-06-20 08:09:40 +00:00
oopsbagel
5a7fc2a063 Merge pull request #387 from oopsbagel/wingtech-ct2mhs01
feat: support Wingtech CT2MHS01 hotspot
2025-06-20 08:00:56 +00:00
Markus Unterwaditzer
e601320b3f Fix broken installer on TP-Link M7350 v9
TP-Link v9 was inadvertently broken via https://github.com/EFForg/rayhunter/pull/390
2025-06-20 02:01:55 +02:00
Cooper Quintin
0b05d1617c enable null cipher heuristic by default in config 2025-06-18 13:32:37 -07:00
Cooper Quintin
e7ba02173a update heuristic docs 2025-06-18 13:32:37 -07:00
Cooper Quintin
0b0dd4ed43 add file extensions to downloads, fixes #397 2025-06-18 13:32:37 -07:00
Markus Unterwaditzer
f2ff1be2ec Disable input keys by default
People say it's too easy to press, see #404
2025-06-18 08:44:26 -07:00
Markus Unterwaditzer
9f9adea5a1 Debounce key input events 2025-06-18 08:05:35 -07:00
Markus Unterwaditzer
cb2092d14f Fix last_message_time showing as 1970 when capture is 0 bytes
This is the same bug as #224, but regressed in the new UI
2025-06-17 16:38:49 -07:00
Cooper Quintin
76cdb3ecf1 re-add warning again 2025-06-17 16:30:22 -07:00
Cooper Quintin
bee5152381 re enable null cipher test 2025-06-17 16:30:22 -07:00
Cooper Quintin
2634271715 re-enable null cipher and update docs 2025-06-17 16:30:22 -07:00
Cooper Quintin
58913314aa helpful comment 2025-06-17 16:30:22 -07:00
Cooper Quintin
4f5bf4aa78 silence logging and point to specific revision and re-add warning 2025-06-17 16:30:22 -07:00
Cooper Quintin
bfc85c5103 re enable null cipher test 2025-06-17 16:30:22 -07:00
Cooper Quintin
7923327ba9 update binary name. Fixes #128 2025-06-17 16:30:22 -07:00
oopsbagel
3ff714972c fix(installer/wingtech): parse json login response
Use serde via reqwest to deserialize the login response from qcmap_auth
when obtaining an authentication token instead of golfing based on the
specific token length.
2025-06-16 00:38:28 -07:00
oopsbagel
a5d8e601d9 Merge pull request #399 from oopsbagel/fix-blame-ignore-rev-file
chore/fix: fix blame ignore rev file rev
2025-06-16 03:42:57 +00:00
oopsbagel
5272a99fb5 chore/fix: fix blame ignore rev file rev
Due to rebasing, this pointed to a nonexistent commit.
2025-06-15 20:40:50 -07:00
oopsbagel
5d61ad53b4 Merge pull request #394 from EFForg/readme-404
Fix broken CI badge in README
2025-06-16 03:32:22 +00:00
Markus Unterwaditzer
88ee4fc87e Fix broken CI badge in README 2025-06-14 17:13:30 +02:00
oopsbagel
8aadfc20f2 fix(installer/wingtech): remove duplicated struct 2025-06-13 22:00:20 -07:00
oopsbagel
a234df1e1e fix(installer/wingtech): only say ok if telnet connected 2025-06-13 21:39:12 -07:00
oopsbagel
de25008742 Merge pull request #392 from EFForg/ci-restore-main-workflow-call
fix(ci): restore deleted workflow_call trigger
2025-06-14 02:45:25 +00:00
oopsbagel
185da9cb36 fix(ci): explain why workflow_call: is needed 2025-06-13 19:42:38 -07:00
oopsbagel
c366eb9e4d fix(ci): restore deleted workflow_call trigger
This is required to call this workflow from another workflow, such as
the release.yml workflow.
2025-06-13 19:38:49 -07:00
oopsbagel
5dfbeaef64 Merge pull request #390 from EFForg/m7310
Add support for TP-Link M7310
2025-06-14 02:36:14 +00:00
oopsbagel
62e4c15eb5 Merge pull request #391 from EFForg/fix-workflow-main
Do not publish docs from any branch
2025-06-14 02:34:35 +00:00
Markus Unterwaditzer
02c98a8e8e Do not publish docs from any branch
Documentation was being pushed from an unrelated PR that I opened,
because that PR was created from a branch pushed to efforg instead of my
own fork.
2025-06-14 02:10:56 +02:00
Markus Unterwaditzer
6c02f56250 Add support for TP-Link M7310
The device is very similar to the M7350, and might as well just be
another hardware version.
2025-06-13 16:04:13 +02:00
oopsbagel
f56acdf89d fix(installer/wingtech): better Response variable 2025-06-12 12:46:08 -07:00
oopsbagel
cb6f79f67a ci: build wingtech 2025-06-12 01:11:01 -07:00
oopsbagel
95951c5c38 fix(display/mod.rs): remove "no device" check
This check is unnecessary, cargo will not build if fn update_ui is
undefined.
2025-06-12 01:04:29 -07:00
oopsbagel
241fb2789b fix(display/mod.rs): remove "many devices" check
This check is unnecessary, cargo will not build if multiple versions of
fn update_ui are defined.
2025-06-12 01:01:05 -07:00
oopsbagel
3a3adb055b fix(installer): wingtech adb error message 2025-06-12 00:45:52 -07:00
oopsbagel
3ae2636d9e chore(installer/build.rs): cargo clippy 2025-06-12 00:44:36 -07:00
oopsbagel
79b2628d2f chore: cargo fmt 2025-06-12 00:43:00 -07:00
oopsbagel
cba898daf6 feat(installer/util): telnet functions in util.rs
Add installer util wingtech-start-telnet command.
Add installer util wingtech-start-adb command.
2025-06-12 00:39:27 -07:00
oopsbagel
cb1df974e4 feat: support Wingtech CT2MHS01 hotspot
Add support for the Wingtech CT2MHS01 hotspot, a Qualcomm mdm9650-based
device with a screen available for US$15-35. This device is often used
as a base platform for while labeled versions like the T-Mobile TMOHS1.
AT&T branded versions of the hotspot seem to be the most abundant.

The device has a framebuffer-driven screen at /dev/fb0 that behaves
similarly to the Orbic RC400L, although the userspace program
`displaygui` refreshes the screen significantly more often than on the
Orbic. This causes the green line on the screen to subtly flicker and
only be displayed during some frames. Subsequent work to fully control
the display without removing the OEM interface is desired.
2025-06-11 18:06:50 -07:00
Markus Unterwaditzer
86e08f9a85 Allow enabling/disabling analyzers from config file (#382)
Co-authored-by: Will Greenberg <willg@eff.org>
2025-06-10 21:37:38 +02:00
Markus Unterwaditzer
fb2149f0c8 Loosen up issue templates
A lot of the bug report we receive are about the web UI or the installer
failing, and there things like capture date just don't matter. We could
create separate templates for these types of bugs, but I'd think it's
probably better to just have one textbox with a few "reminder" questions
that are all optional.

Feature request template I think doesn't have this issue.

Also allow the creation of blank issues, because some issues are more
related to CI or devenv and don't neatly fit in any category. Let's just
hope nobody abuses that?
2025-06-10 11:49:56 -07:00
oopsbagel
bf2b00ce47 doc(faq): send commands with installer util shell 2025-06-10 11:49:20 -07:00
oopsbagel
fd453900c2 doc(faq): format code correctly 2025-06-10 11:49:20 -07:00
oopsbagel
3d29c5f306 doc: add re-enabling USB tethering to the FAQ
Fixes issue #294
2025-06-10 11:49:20 -07:00
jrddupont
74623dea02 doc: Fix instructions for mac quarantine bit (#385) 2025-06-10 00:33:12 +02:00
Cooper Quintin
48e73a0a41 revert accidental futures upgrade 2025-06-06 12:44:39 -07:00
Cooper Quintin
a36863e002 bump version 2025-06-06 12:44:39 -07:00
oopsbagel
48aac0f0bb Merge pull request #380 from oopsbagel/fix-ci-env
ci: don't try to use env:
2025-06-06 19:33:50 +00:00
oopsbagel
5749c305c6 ci: don't try to use env:
I apparently don't know how this works.
2025-06-06 12:30:36 -07:00
oopsbagel
f53688086d ci: minor syntax and workflow order fixes 2025-06-06 12:21:24 -07:00
oopsbagel
bd2e0b4394 ci: release from actions, only test changed files
This commit introduces release automation triggered by button clicks in
Github Actions, guarded by a check on whether all the Cargo.toml files
contain the same version string.

On PRs, changes to documentation no longer trigger code tests.
Similarly, changes to code that don't update documentation do not
trigger documentation tests. Changes that fail at the `cargo check`
stage abort early to prevent lengthy CI builds of the installer and
firmware.

Commits on the `main` branch always run the full test suite regardless
of what changed.

Releases also run the full check, test, build and publish suite.
2025-06-06 12:21:24 -07:00
alli.sys
1eea086199 Merge pull request #375 from EFForg/eff-adb
Switch to the EFF org fork of adb_client
2025-06-05 21:28:06 -04:00
Sashanoraa
d36c1f10cd Switch to the EFF org fork of adb_client 2025-06-05 20:22:43 -04:00
Markus Unterwaditzer
8d8d2bd8ec Fix crash when deleting recordings
Due to a refactor in https://github.com/EFForg/rayhunter/pull/350, we
had to move more into the shared codepath of StopRecording. The todo!()
used to be unreachable when it was just in the stop-recording endpoint.

Fix #367
2025-06-05 09:28:31 -07:00
Cooper Quintin
f2b722ad5f version update 2025-06-04 10:12:08 -07:00
Cooper Quintin
5e2058e7ac update make to use firmware profile 2025-06-04 10:12:08 -07:00
cooperq
60daf4b716 update win docs 2025-06-04 10:11:12 -07:00
Cooper Quintin
4df317b028 dig deeper in the json tree. Fixes #360 2025-06-04 10:10:18 -07:00
Sashanoraa
d7fb8b9c85 Move most serial commands to usb shared USB claim with adb 2025-06-04 09:11:06 -07:00
Markus Unterwaditzer
d399532494 Add documentation for key input 2025-06-03 14:29:04 -07:00
Cooper Quintin
45df91a364 Update using-rayhunter.md 2025-06-03 14:23:50 -07:00
Matej Kovacic
672ed8c6c6 Update using-rayhunter.md 2025-06-03 14:23:50 -07:00
Matej Kovacic
5c7c7cd766 Add files via upload 2025-06-03 14:21:19 -07:00
Markus Unterwaditzer
f41a8d38fe move analysis into diag reader thread as well 2025-06-03 13:58:47 -07:00
Markus Unterwaditzer
f9c8c4671e Add basic key input 2025-06-03 13:58:47 -07:00
Markus Unterwaditzer
723b20541e Move business logic out of axum handlers 2025-06-03 13:58:47 -07:00
cooperq
272a4aeabf update docs 2025-06-03 11:49:20 -07:00
cooperq
6ae70556ba fix windows powershell installer and rust installer root process 2025-06-03 11:49:20 -07:00
255 changed files with 45942 additions and 10321 deletions

11
.cargo/audit.toml Normal file
View File

@@ -0,0 +1,11 @@
[advisories]
ignore = [
# RSA Marvin Attack in `rsa`, dragged in through rustcrypto (dev builds)
# and adb_client (USB signing only, unrelated to marvin attack which
# targets decryption).
"RUSTSEC-2023-0071",
# paste crate being unmaintained is not important. it's not dealing with
# user-input. we could get rid of this warning by disabling the image
# dependency in adb-client.
"RUSTSEC-2024-0436",
]

View File

@@ -1,3 +1,15 @@
[alias]
# Build the daemon with "firmware" profile and "ring" TLS backend.
# Requires a cross-compiler (see github actions workflows) and is very slow to build.
build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features ring-tls"
# Build the daemon with "firmware-devel" profile and "rustcrypto" backend.
# Works with just the Rust toolchain, and is medium-slow to build. Binaries are slightly larger.
build-daemon-firmware-devel = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware-devel"
# Build rootshell for firmware
build-rootshell-firmware = "build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware"
# Build rootshell for development
build-rootshell-firmware-devel = "build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware-devel"
[target.aarch64-apple-darwin]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
@@ -15,6 +27,10 @@ rustflags = ["-C", "target-feature=+crt-static"]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabi]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
# Disable rust-lld for x86 macOS because the linker crashers when compiling
# the installer in release mode with debug info on.
# [target.x86_64-apple-darwin]
@@ -25,17 +41,22 @@ rustflags = ["-C", "target-feature=+crt-static"]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
# keep line numbers in stack traces for non-firmware binaries
[profile.release]
# keep line numbers in stack traces for non-firmware binaries
debug = "limited"
lto = "fat"
opt-level = "z"
strip = "debuginfo"
[profile.firmware-devel]
inherits = "release"
opt-level = "s"
lto = false
# optimizations to reduce the binary size of firmware binaries
[profile.firmware]
inherits = "release"
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
debug = false

View File

@@ -1 +1,2 @@
c5bbaabe15d4ccfee97b9997a13569fbfea13c45
9fe75ac961c57e508bf7488ce51d596750fa8d37
76ffdf6bada515c9a5f63a600e6f1502288c147a

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
# Files that are distributed onto the Rayhunter device always have to have
# Unix-style line endings, even if the installer is built on Windows with
# autocrlf enabled.
# Using CRLF for the init scripts will make them fail to execute on TP-Link.
# See https://github.com/EFForg/rayhunter/issues/489
dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf

View File

@@ -1,59 +1,25 @@
name: Bug Report
description: File a bug report.
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
- type: checkboxes
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
attributes:
label: Rayhunter Version
description: |
Which version did you install?
placeholder: "v0.2.6"
- type: input
attributes:
label: Capture Date
description: |
YYYY-MM-DD
placeholder: "2025-05-01"
validations:
required: true
- type: input
attributes:
label: Capture Location
description: |
(If comfortable disclosing) What region or country were you in?
placeholder: Washington State
- type: input
attributes:
label: Device and Model
description: |
Device you installed Rayhunter on to.
placeholder: Orbic RC400L
validations:
required: true
label: Prerequisites
options:
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
label: Bug Report Details
description: |
What steps did you take to get to your issue?
placeholder: "Tell us what you see!"
Please provide the following information, if applicable:
placeholder: |
• **Rayhunter Version**: (e.g., v0.2.6)
• **Capture Date**: (YYYY-MM-DD, e.g., 2025-05-01)
• **Capture Location**: (If comfortable disclosing, what region or country were you in? e.g., Washington State)
• **Device and Model**: (Device you installed Rayhunter on, e.g., Orbic RC400L)
• **What happened?**: (What steps did you take to get to your issue? Tell us what you see!)
• **Expected behavior**: (Rayhunter's behavior differed from what I expected because...)
• **Relevant log output**: (Rayhunter data captures - QMDL and PCAP logs - or error codes)
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Rayhunter's behavior differed from what I expected because.
placeholder: "What was expected?"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Rayhunter data captures (QMDL and PCAP logs) or error codes
render: shell

View File

@@ -1,8 +1,10 @@
blank_issues_enabled: false
contact_links:
- name: Rayhunter Mattermost
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
- name: Frequently Asked Questions
url: https://efforg.github.io/rayhunter/faq.html
- name: Questions and community
url: https://efforg.github.io/rayhunter/support-feedback-community.html
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help on GitHub discussions or Mattermost
- name: Rayhunter Security Policy
url: https://github.com/EFForg/rayhunter/security/advisories/new
about: Please report security vulnerabilities here.

View File

@@ -1,8 +1,13 @@
name: Feature Request
description: Suggest a new feature or improvement to Rayhunter
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
required: true
- type: textarea
id: problem
attributes:

View File

@@ -0,0 +1,53 @@
name: Installer Issue
description: File an bug related to an installer issue.
labels: ["bug", "installer"]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
required: true
- type: input
attributes:
label: Rayhunter Version
placeholder: 'v0.5.0'
validations:
required: true
- type: dropdown
attributes:
label: Device
description: |
What device are you trying to install Rayhunter on?
options:
- Orbic RC400L
- Tplink M7350
- Tplink M7310
- Tmobile TMOHS1
- Wingtech CT2MHS0
- Pinephone
- Other / I'm not sure
validations:
required: true
- type: dropdown
attributes:
label: Installer OS
description: What operating system are running the installer from
multiple: false
options:
- Linux
- macOS
- Windows
validations:
required: true
- type: textarea
attributes:
label: Describe the Issue
description: |
Please describe the issue you're having installing Rayhunter.
Include the logs outputed by the installer program. If the installer
is crashing, please try running the installer with `RUST_BACKTRACE=1`
environment variable set so we can see exactly where the installer is
crashing.
validations:
required: true

View File

@@ -1,6 +1,8 @@
## Pull Request Checklist
- [ ] The Rayhunter team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
- [ ] The Rayhunter team has recently expressed interest in reviewing a PR for this.
- If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
- [ ] Added or updated any documentation as needed to support the changes in this PR.
- [ ] Code has been linted and run through `cargo fmt`
- [ ] If any new functionality has been added, unit tests were also added
- [ ] Code has been linted and run through `cargo fmt`.
- [ ] If any new functionality has been added, unit tests were also added.
- [ ] [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md) has been read.

View File

@@ -1,162 +0,0 @@
name: Build Release
on:
push:
branches: [main, "release-*"]
pull_request:
branches: ["main"]
env:
CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon
jobs:
build_rayhunter_check:
strategy:
matrix:
platform:
- name: ubuntu-24
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: ubuntu-24-aarch64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- name: Build rayhunter-check
run: cargo build --bin rayhunter-check --release
- uses: actions/upload-artifact@v4
with:
name: rayhunter-check-${{ matrix.platform.name }}
path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_rootshell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- name: Build rootshell (arm32)
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4
with:
name: rootshell
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
if-no-files-found: error
build_rayhunter:
strategy:
matrix:
device:
- name: tplink
- name: orbic
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- name: Build rayhunter-daemon (arm32)
run: |
pushd bin/web
npm install
npm run build
popd
# Run with -p so that cargo will select the minimum feature set for this package.
#
# Otherwise, it will consider the union of all requested features
# from all packages in the workspace. For example, if installer
# requires tokio with "full" feature, it will be included no matter
# what the feature selection in rayhunter-daemon is.
#
# https://github.com/rust-lang/cargo/issues/4463
cargo build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile=firmware --no-default-features --features ${{ matrix.device.name }}
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon-${{ matrix.device.name }}
path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
if-no-files-found: error
build_rust_installer:
needs:
- build_rayhunter
strategy:
matrix:
platform:
- name: ubuntu-24
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: ubuntu-24-aarch64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: installer-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_release_zip:
needs:
- build_rayhunter_check
- build_rootshell
- build_rayhunter
- build_rust_installer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon
- name: Get Rayhunter version
id: get_version
run: echo "VERSION=$(grep '^version' bin/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
- name: Setup versioned release directory
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
mkdir "$VERSIONED_DIR"
mv rayhunter-daemon-* rootshell/rootshell installer-* dist/* installer/install.ps1 "$VERSIONED_DIR"/
- name: Archive release directory as zip
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
zip -r "$VERSIONED_DIR.zip" "$VERSIONED_DIR"
- name: Compute SHA256 of zip
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
sha256sum "$VERSIONED_DIR.zip" > "$VERSIONED_DIR.zip.sha256"
# TODO: have this create a release directly
- name: Upload zip release and sha256
uses: actions/upload-artifact@v4
with:
name: rayhunter-v${{ env.VERSION }}
path: |
rayhunter-v${{ env.VERSION }}.zip
rayhunter-v${{ env.VERSION }}.zip.sha256
if-no-files-found: error

View File

@@ -1,54 +0,0 @@
name: Check and Test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
NO_FIRMWARE_BIN: true
jobs:
check_and_test:
strategy:
matrix:
device:
- name: tplink
- name: orbic
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check
run: |
pushd bin/web
npm install
npm run build
popd
cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run tests
run: |
pushd bin/web
npm install
npm run build
popd
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run clippy
run: cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
windows_installer_check_and_test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: cargo check
shell: bash
run: |
cd installer
cargo check --verbose
- name: cargo test
shell: bash
run: |
cd installer
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}

585
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,585 @@
name: main
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_call: # required to call this workflow from another workflow like release.yml
env:
CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
RUSTFLAGS: "-Dwarnings"
jobs:
files_changed:
name: Detect file changes
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
code_changed: ${{ steps.files_changed.outputs.code_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' }}
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' }}
installer_changed: ${{ steps.files_changed.outputs.installer_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' }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: detect file changes
id: files_changed
run: |
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, if workflow/cargo config files changed, or if
# the latest commit message contains "#build-all", run everything.
# Use #build-all in a commit message to force a full build on a PR
# branch (useful for testing release builds without merging to main).
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo || git log -1 --format='%s %b' | grep -qF '#build-all'
then
echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT"
echo daemon_count=forced >> "$GITHUB_OUTPUT"
echo web_count=forced >> "$GITHUB_OUTPUT"
echo docs_count=forced >> "$GITHUB_OUTPUT"
echo installer_build=forced >> "$GITHUB_OUTPUT"
echo installer_count=forced >> "$GITHUB_OUTPUT"
echo installer_gui_count=forced >> "$GITHUB_OUTPUT"
echo rootshell_count=forced >> "$GITHUB_OUTPUT"
else
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^installer -e ^check -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "daemon_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^lib -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "web_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon/web | wc -l)" >> "$GITHUB_OUTPUT"
echo "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer/ | wc -l)
installer_gui_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer-gui | wc -l)
if [ $installer_count != "0" ] || [ $installer_gui_count != "0" ]; then
echo "installer_build=1" >> "$GITHUB_OUTPUT"
else
echo "installer_build=0" >> "$GITHUB_OUTPUT"
fi
echo "installer_count=$installer_count" >> "$GITHUB_OUTPUT"
echo "installer_gui_count=$installer_gui_count" >> "$GITHUB_OUTPUT"
fi
mdbook_test:
name: Test mdBook Documentation builds
needs: files_changed
if: needs.files_changed.outputs.docs_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_publish:
name: Publish mdBook to Github Pages
needs: mdbook_test
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4
check_and_test:
needs: files_changed
if: needs.files_changed.outputs.code_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --all --check
- name: Check
run: |
pushd daemon/web
npm install
npm run build
popd
NO_FIRMWARE_BIN=true cargo check --verbose
- name: Run tests
run: |
NO_FIRMWARE_BIN=true cargo test --verbose
- name: Run clippy
run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose
installer_gui_check:
# we test the GUI installer separately to:
# 1) mimic the default behavior of cargo commands for rayhunter devs where
# installer-gui isn't one of the default workspace packages
# 2) avoid slowing down development on changes unrelated to the GUI installer
needs: files_changed
if: needs.files_changed.outputs.installer_gui_changed == 'true'
# we run this on macos simply because no additional OS packages need to be
# installed
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
# we don't need to run cargo fmt here because both cargo fmt and cargo
# fmt --all runs on all workspace packages so this is handled by
# check_and_test above
- name: Check
run: NO_FIRMWARE_BIN=true cargo check --package installer-gui --verbose
- name: Run clippy
run: NO_FIRMWARE_BIN=true cargo clippy --package installer-gui --verbose
test_daemon_frontend:
needs: files_changed
if: needs.files_changed.outputs.web_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: daemon/web
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm install
- run: npm run lint
- run: npm run check
- run: npm run test
test_installer_frontend:
needs: files_changed
if: needs.files_changed.outputs.installer_gui_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: installer-gui
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm install
- run: npm run lint
- run: npm run check
windows_installer_check_and_test:
needs: files_changed
if: needs.files_changed.outputs.installer_changed == 'true'
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: cargo check
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo check --verbose
- name: cargo test
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
build_rayhunter_check:
if: needs.files_changed.outputs.daemon_changed == 'true'
needs:
- check_and_test
- files_changed
permissions:
contents: read
packages: write
strategy:
matrix:
platform:
- name: linux-x64
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: linux-armv7
os: ubuntu-latest
target: armv7-unknown-linux-musleabi
- name: linux-aarch64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-latest
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-check
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: rayhunter-check-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_rootshell:
if: needs.files_changed.outputs.rootshell_needed == 'true'
needs:
- check_and_test
- files_changed
runs-on: ubuntu-latest
permissions:
contents: read
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 rootshell (armv7)
run: cargo build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4
with:
name: rootshell
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
if-no-files-found: error
build_rayhunter:
if: needs.files_changed.outputs.daemon_needed == 'true'
needs:
- check_and_test
- files_changed
permissions:
contents: read
packages: 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: Install ARM cross-compilation toolchain
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
- name: Build rayhunter-daemon (armv7)
run: |
pushd daemon/web
npm install
npm run build
popd
# Run with -p so that cargo will select the minimum feature set for this package.
#
# Otherwise, it will consider the union of all requested features
# from all packages in the workspace. For example, if installer
# requires tokio with "full" feature, it will be included no matter
# what the feature selection in rayhunter-daemon is.
#
# https://github.com/rust-lang/cargo/issues/4463
CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon
path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
if-no-files-found: error
build_rust_installer:
if: needs.files_changed.outputs.installer_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- windows_installer_check_and_test
strategy:
matrix:
platform:
- name: linux-x64
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: linux-armv7
os: ubuntu-latest
target: armv7-unknown-linux-musleabi
- name: linux-aarch64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-latest
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: installer-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_installer_gui_linux:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- installer_gui_check
- test_installer_frontend
strategy:
matrix:
platform:
# we want to use the oldest supported version of ubuntu here to
# maximize compatibility with older versions of glibc
- name: linux-x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: linux-aarch64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- name: Install tauri dependencies
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev xdg-utils
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-appimage
path: target/${{ matrix.platform.target }}/release/bundle/appimage/*.AppImage
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-deb
path: target/${{ matrix.platform.target }}/release/bundle/deb/*.deb
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-rpm
path: target/${{ matrix.platform.target }}/release/bundle/rpm/*.rpm
if-no-files-found: error
build_installer_gui_macos:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- installer_gui_check
- test_installer_frontend
strategy:
matrix:
platform:
- name: macos-arm
target: aarch64-apple-darwin
- name: macos-intel
target: x86_64-apple-darwin
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ matrix.platform.target }}
cd ..
mv "target/${{ matrix.platform.target }}/release/bundle/macos/"*.app .
zip -r "rayhunter-installer-${{ matrix.platform.name }}.app.zip" ./*.app
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-app
path: ./*.app.zip
if-no-files-found: error
build_installer_gui_windows:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- installer_gui_check
- test_installer_frontend
env:
TARGET: x86_64-pc-windows-msvc
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ env.TARGET }}
- uses: Swatinem/rust-cache@v2
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ env.TARGET }}
- uses: actions/upload-artifact@v4
with:
name: gui-installer-msi
path: target/${{ env.TARGET }}/release/bundle/msi/*.msi
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-exe
path: target/${{ env.TARGET }}/release/bundle/nsis/*.exe
if-no-files-found: error
build_release_zip:
permissions:
contents: read
packages: write
needs:
- build_rayhunter_check
- build_rootshell
- build_rayhunter
- build_rust_installer
runs-on: ubuntu-latest
strategy:
matrix:
platform:
- linux-x64
- linux-aarch64
- linux-armv7
- macos-intel
- macos-arm
- windows-x86_64
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon
- name: Get Rayhunter version
id: get_version
run: echo "VERSION=$(grep '^version' daemon/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
- name: Setup versioned release directory
run: |
platform="${{ matrix.platform }}"
dest="rayhunter-v${VERSION}-${{ matrix.platform }}"
mkdir "$dest"
# Handle installer with proper extension for Windows
if [ "$platform" = "windows-x86_64" ]; then
mv installer-$platform/installer.exe "$dest"/installer.exe
else
mv installer-$platform/installer "$dest"/installer
fi
cp -r rayhunter-check-* rayhunter-daemon dist/scripts "$dest"/
zip -r "$dest.zip" "$dest"
sha256sum "$dest.zip" > "$dest.zip.sha256"
- name: Upload zip release and sha256
uses: actions/upload-artifact@v4
with:
name: rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}
path: |
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
if-no-files-found: error

View File

@@ -1,47 +0,0 @@
# On Repository Settings > Pages > Build and deployment
# Set "Source" to GitHub Actions.
name: Documentation
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
mdbook_test:
name: Test mdBook Documentation builds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_publish:
if: ${{ github.event_name != 'pull_request' }}
needs: mdbook_test
permissions:
pages: write
contents: write
id-token: write
name: Publish mdBook to Github Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4

52
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
# To use: navigate on Github to Actions, select "Release rayhunter" on the left, click "Run workflow" > "Run workflow" on the right.
# https://github.com/EFForg/rayhunter/actions/workflows/release.yml
name: Release rayhunter
on:
workflow_dispatch:
env:
GH_TOKEN: ${{ github.token }}
jobs:
check_version_same:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Ensure all Cargo.toml files have the same version defined.
run: |
defined_versions=$(find lib check daemon installer installer-gui rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
find lib check daemon installer installer-gui rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
echo number of defined versions = $defined_versions
if [ $defined_versions != "1" ]
then
echo "all Cargo.toml files must have the same version defined"
exit 1
fi
main:
needs: check_version_same
permissions:
contents: write
id-token: write
packages: write
pages: write
uses: ./.github/workflows/main.yml
release:
runs-on: ubuntu-latest
needs: main
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- name: Create release
run: |
version=$(grep ^version lib/Cargo.toml | cut -d' ' -f3 | tr -d '"')
gh release create --generate-notes -t "Rayhunter v$version" "v$version" rayhunter-v${version}-*/rayhunter-v${version}*.zi*

75
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,75 @@
# How to contribute to Rayhunter
## Filing issues and starting discussions
Our issue tracker is [on GitHub](https://github.com/EFForg/rayhunter/issues).
- If your rayhunter has found an IMSI-catcher, we strongly encourage you to
[send us that information
privately.](https://efforg.github.io/rayhunter/faq.html#help-rayhunters-line-is-redorangeyellowdotteddashed-what-should-i-do) via Signal.
- Issues should be actionable. If you don't have a
specific feature request or bug report, consider [creating a
discussion](https://github.com/EFForg/rayhunter/discussions) or [joining our Mattermost](https://efforg.github.io/rayhunter/support-feedback-community.html) instead.
Example of a good bug report:
- "Installer broken on TP-Link M7350 v3.0"
- "Display does not update to green after finding"
- "The documentation is wrong" (though we encourage you to file a pull request directly)
Example of a good feature request:
- "Use LED on device XYZ for showing recording status"
Example of something that belongs into discussion:
- "In region XYZ, do I need an activated SIM?"
- "Where to buy this device in region XYZ?"
- "Can this device be supported?" While this is a valid feature
request, we just get this request too often, and without some exploratory
work done upfront it's often unclear initially if that device can be
supported at all.
- The issue templates are mostly there to give you a clue what kind of
information is needed from you, and whether your request belongs into the issue
tracker. Fill them out to be on the safe side, but they are not mandatory.
## Contributing patches
To edit documentation or fix a bug, make a pull request. If you're about to
write a substantial amount of code or implement a new feature, we strongly
encourage you to talk to us before implementing it or check if any issues have
been opened for it already. Otherwise there is a chance we will reject your
contribution after you have spent time on it.
On the other hand, for small documentation fixes you can file a PR without
filing an issue.
Otherwise:
- Refer to [installing from
source](https://efforg.github.io/rayhunter/installing-from-source.html) for
how to build Rayhunter from the git repository.
- Ensure that `cargo fmt` and `cargo clippy` have been run.
- If you add new features, please do your best to both write tests for and also
manually test them. Our test coverage isn't great, but as new features are
added we are trying to prevent it from becoming worse.
If you have any questions [feel free to open a discussion or chat with us on Mattermost.](https://efforg.github.io/rayhunter/support-feedback-community.html)
## Making releases
This one is for maintainers of Rayhunter.
1. Make a PR changing the versions in `Cargo.toml` and other files.
This could be automated better but right now it's manual. You can do this easily with sed:
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml`
2. Merge PR and make a tag.
3. [Run release workflow.](https://github.com/EFForg/rayhunter/actions/workflows/release.yml)
4. Write changelog, edit it into the release, announce on mattermost.

4322
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,20 @@
members = [
"lib",
"bin",
"daemon",
"check",
"rootshell",
"telcom-parser",
"installer",
"installer-gui/src-tauri",
]
# at least for now, let's keep installer-gui out of the list of default
# packages. installer-gui is still experimental and requires many new packages
# both from cargo and the underlying operating system
default-members = [
"lib",
"daemon",
"check",
"rootshell",
"telcom-parser",
"installer",

View File

@@ -1,7 +1,19 @@
# Rayhunter
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
# Rayhunter
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts, it can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html).
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg)
&rarr; Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started.
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
&rarr; To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
&rarr; For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)!
&rarr; To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
*Good Hunting!*

View File

@@ -1,176 +0,0 @@
use clap::Parser;
use futures::TryStreamExt;
use log::{info, warn};
use rayhunter::{
analysis::analyzer::{EventType, Harness},
diag::DataType,
gsmtap_parser,
pcap::GsmtapPcapWriter,
qmdl::QmdlReader,
};
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
use tokio::fs::{metadata, read_dir, File};
mod dummy_analyzer;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[arg(short = 'p', long)]
qmdl_path: PathBuf,
#[arg(short = 'c', long)]
pcapify: bool,
#[arg(long)]
show_skipped: bool,
#[arg(long)]
enable_dummy_analyzer: bool,
#[arg(short, long)]
verbose: bool,
}
async fn analyze_file(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_all_analyzers();
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
}
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
let file_size = qmdl_file
.metadata()
.await
.expect("failed to get QMDL file metadata")
.len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin!(qmdl_reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
let mut skipped_reasons: HashMap<String, i32> = HashMap::new();
let mut total_messages = 0;
let mut warnings = 0;
let mut skipped = 0;
while let Some(container) = qmdl_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
let row = harness.analyze_qmdl_messages(container);
total_messages += 1;
for reason in row.skipped_message_reasons {
*skipped_reasons.entry(reason).or_insert(0) += 1;
skipped += 1;
}
for analysis in row.analysis {
for maybe_event in analysis.events {
let Some(event) = maybe_event else { continue };
match event.event_type {
EventType::Informational => {
info!(
"{}: INFO - {} {}",
qmdl_path, analysis.timestamp, event.message,
);
}
EventType::QualitativeWarning { severity } => {
warn!(
"{}: WARNING (Severity: {:?}) - {} {}",
qmdl_path, severity, analysis.timestamp, event.message,
);
warnings += 1;
}
}
}
}
}
if show_skipped && skipped > 0 {
info!("{}: messages skipped:", qmdl_path);
for (reason, count) in skipped_reasons.iter() {
info!(" - {}: \"{}\"", count, reason);
}
}
info!(
"{}: {} messages analyzed, {} warnings, {} messages skipped",
qmdl_path, total_messages, warnings, skipped
);
}
async fn pcapify(qmdl_path: &PathBuf) {
let qmdl_file = &mut File::open(&qmdl_path)
.await
.expect("failed to open qmdl file");
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
let mut pcap_path = qmdl_path.clone();
pcap_path.set_extension("pcap");
let pcap_file = &mut File::create(&pcap_path)
.await
.expect("failed to open pcap file");
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
pcap_writer.write_iface_header().await.unwrap();
while let Some(container) = qmdl_reader
.get_next_messages_container()
.await
.expect("failed to get container")
{
for msg in container.into_messages().into_iter().flatten() {
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
pcap_writer
.write_gsmtap_message(parsed, timestamp)
.await
.expect("failed to write");
}
}
}
info!("wrote pcap to {:?}", &pcap_path);
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let level = if args.verbose {
log::LevelFilter::Trace
} else {
log::LevelFilter::Warn
};
simple_logger::SimpleLogger::new()
.with_colors(true)
.without_timestamps()
.with_level(level)
.init()
.unwrap();
info!("Analyzers:");
let mut harness = Harness::new_with_all_analyzers();
if args.enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
}
for analyzer in harness.get_metadata().analyzers {
info!(" - {}: {}", analyzer.name, analyzer.description);
}
let metadata = metadata(&args.qmdl_path)
.await
.expect("failed to get metadata");
if metadata.is_dir() {
let mut dir = read_dir(&args.qmdl_path).await.expect("failed to read dir");
while let Some(entry) = dir.next_entry().await.expect("failed to get entry") {
let name = entry.file_name();
let name_str = name.to_str().unwrap();
if name_str.ends_with(".qmdl") {
let path = entry.path();
let path_str = path.to_str().unwrap();
analyze_file(args.enable_dummy_analyzer, path_str, args.show_skipped).await;
if args.pcapify {
pcapify(&path).await;
}
}
}
} else {
let path = args.qmdl_path.to_str().unwrap();
analyze_file(args.enable_dummy_analyzer, path, args.show_skipped).await;
if args.pcapify {
pcapify(&args.qmdl_path).await;
}
}
}

View File

@@ -1,325 +0,0 @@
use std::pin::pin;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info};
use rayhunter::diag::DataType;
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use tokio::fs::File;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::RwLock;
use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display;
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording((QmdlWriter<File>, File)),
Exit,
}
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
enable_dummy_analyzer: bool,
) {
task_tracker.spawn(async move {
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer).await
.expect("failed to create analysis writer"));
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording((new_writer, new_analysis_file))) => {
maybe_qmdl_writer = Some(new_writer);
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await
.expect("failed to write to analysis file"));
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
maybe_qmdl_writer = None;
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = None;
},
// None means all the Senders have been dropped, so it's
// time to go
Some(DiagDeviceCtrlMessage::Exit) | None => {
info!("Diag reader thread exiting...");
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
return Ok(())
},
}
}
maybe_container = diag_stream.next() => {
match maybe_container.unwrap() {
Ok(container) => {
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
continue;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
let mut qmdl_store = qmdl_store_lock.write().await;
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
.expect("failed to update qmdl file size");
debug!("done!");
} else {
debug!("no qmdl_writer set, continuing...");
}
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
let analysis_output = analysis_writer.analyze(container).await
.expect("failed to analyze container");
let (analysis_file_len, heuristic_warning) = analysis_output;
if heuristic_warning {
info!("a heuristic triggered on this run!");
ui_update_sender.send(display::DisplayState::WarningDetected).await
.expect("couldn't send ui update message: {}");
}
let mut qmdl_store = qmdl_store_lock.write().await;
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store.update_entry_analysis_size(index, analysis_file_len).await
.expect("failed to update analysis file size");
}
},
Err(err) => {
error!("error reading diag device: {}", err);
return Err(err);
}
}
}
}
}
});
}
pub async fn start_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
let (qmdl_file, analysis_file) = qmdl_store.new_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't create new qmdl entry: {}", e),
)
})?;
let qmdl_writer = QmdlWriter::new(qmdl_file);
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StartRecording((
qmdl_writer,
analysis_file,
)))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
let display_state = display::DisplayState::Recording;
state
.ui_update_sender
.send(display_state)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
match qmdl_store.get_current_entry() {
Some((_, entry)) => {
state
.analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send AnalysisCtrlMessage: {}", e),
)
})?;
}
None => todo!(),
}
qmdl_store.close_current_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't close current qmdl entry: {}", e),
)
})?;
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn delete_recording(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
match qmdl_store.delete_entry(&qmdl_name).await {
Err(RecordingStoreError::NoSuchEntryError) => {
return Err((
StatusCode::BAD_REQUEST,
format!("no recording with name {qmdl_name}"),
))
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"),
))
}
Ok(_) => {}
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.delete_all_entries().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete all recordings: {}", e),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn get_analysis_report(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, _) = if qmdl_name == "live" {
qmdl_store.get_current_entry().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
))?
} else {
qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND,
format!("Couldn't find QMDL entry with name \"{}\"", qmdl_name),
))?
};
let analysis_file = qmdl_store
.open_entry_analysis(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
let analysis_stream = ReaderStream::new(analysis_file);
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
let body = Body::from_stream(analysis_stream);
Ok((headers, body).into_response())
}

View File

@@ -1,202 +0,0 @@
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
use std::io::Cursor;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
use log::{error, info};
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker;
use std::thread::sleep;
use include_dir::{include_dir, Dir};
#[derive(Copy, Clone)]
pub struct Dimensions {
pub height: u32,
pub width: u32,
}
#[allow(dead_code)]
#[derive(Copy, Clone)]
pub enum Color {
Red,
Green,
Blue,
White,
Black,
Cyan,
Yellow,
Pink,
}
impl Color {
fn rgb(self) -> (u8, u8, u8) {
match self {
Color::Red => (0xff, 0, 0),
Color::Green => (0, 0xff, 0),
Color::Blue => (0, 0, 0xff),
Color::White => (0xff, 0xff, 0xff),
Color::Black => (0, 0, 0),
Color::Cyan => (0, 0xff, 0xff),
Color::Yellow => (0xff, 0xff, 0),
Color::Pink => (0xfe, 0x24, 0xff),
}
}
}
impl Color {
fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
match state {
DisplayState::Paused => Color::White,
DisplayState::Recording => {
if colorblind_mode {
Color::Blue
} else {
Color::Green
}
}
DisplayState::WarningDetected => Color::Red,
}
}
}
pub trait GenericFramebuffer: Send + 'static {
fn dimensions(&self) -> Dimensions;
fn write_buffer(
&mut self,
buffer: &[(u8, u8, u8)], // rgb, row-wise, left-to-right, top-to-bottom
);
fn write_dynamic_image(&mut self, img: DynamicImage) {
let dimensions = self.dimensions();
let mut width = img.width();
let mut height = img.height();
let resized_img: DynamicImage;
if height > dimensions.height || width > dimensions.width {
resized_img = img.resize(dimensions.width, dimensions.height, FilterType::CatmullRom);
width = dimensions.width.min(resized_img.width());
height = dimensions.height.min(resized_img.height());
} else {
resized_img = img;
}
let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::new();
for y in 0..height {
for x in 0..width {
let px = img_rgba8.get_pixel(x, y);
buf.push((px[0], px[1], px[2]));
}
}
self.write_buffer(&buf);
}
fn draw_gif(&mut self, img_buffer: &[u8]) {
// this is dumb and i'm sure there's a better way to loop this
let cursor = Cursor::new(img_buffer);
let decoder = GifDecoder::new(cursor).unwrap();
for maybe_frame in decoder.into_frames() {
let frame = maybe_frame.unwrap();
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
self.write_dynamic_image(img);
std::thread::sleep(Duration::from_millis(numerator as u64));
}
}
fn draw_img(&mut self, img_buffer: &[u8]) {
let img = image::load_from_memory(img_buffer).unwrap();
self.write_dynamic_image(img);
}
fn draw_line(&mut self, color: Color, height: u32) {
let width = self.dimensions().width;
let px_num = height * width;
let mut buffer = Vec::new();
for _ in 0..px_num {
buffer.push(color.rgb());
}
self.write_buffer(&buffer);
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut fb: impl GenericFramebuffer,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>,
) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
let colorblind_mode = config.colorblind_mode;
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
task_tracker.spawn_blocking(move || {
// this feels wrong, is there a more rusty way to do this?
let mut img: Option<&[u8]> = None;
if display_level == 2 {
img = Some(
IMAGE_DIR
.get_file("orca.gif")
.expect("failed to read orca.gif")
.contents(),
);
} else if display_level == 3 {
img = Some(
IMAGE_DIR
.get_file("eff.png")
.expect("failed to read eff.png")
.contents(),
);
}
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(state) => {
display_color = Color::from_state(state, colorblind_mode);
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving framebuffer update message: {e}"),
}
match display_level {
2 => {
fb.draw_gif(img.unwrap());
}
3 => fb.draw_img(img.unwrap()),
128 => {
fb.draw_line(Color::Cyan, 128);
fb.draw_line(Color::Pink, 102);
fb.draw_line(Color::White, 76);
fb.draw_line(Color::Pink, 50);
fb.draw_line(Color::Cyan, 25);
}
_ => {
// this branch id for ui_level 1, which is also the default if an
// unknown value is used
fb.draw_line(display_color, 2);
}
};
sleep(Duration::from_millis(1000));
}
});
}

View File

@@ -1,28 +0,0 @@
mod generic_framebuffer;
#[cfg(feature = "tplink")]
mod tplink;
#[cfg(feature = "tplink")]
mod tplink_framebuffer;
#[cfg(feature = "tplink")]
mod tplink_onebit;
#[cfg(feature = "tplink")]
pub use tplink::update_ui;
#[cfg(feature = "orbic")]
mod orbic;
#[cfg(feature = "orbic")]
pub use orbic::update_ui;
pub enum DisplayState {
Recording,
Paused,
WarningDetected,
}
#[cfg(all(feature = "orbic", feature = "tplink"))]
compile_error!("cannot compile for many devices at once");
#[cfg(not(any(feature = "orbic", feature = "tplink")))]
compile_error!("cannot compile for no device at all");

View File

@@ -1,51 +0,0 @@
use std::borrow::Cow;
use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, PagingUE_Identity};
use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity};
use rayhunter::analysis::information_element::{InformationElement, LteInformationElement};
pub struct TestAnalyzer {
pub count: i32,
}
impl Analyzer for TestAnalyzer {
fn get_name(&self) -> Cow<str> {
Cow::from("Example Analyzer")
}
fn get_description(&self) -> Cow<str> {
Cow::from("Always returns true, if you are seeing this you are either a developer or you are about to have problems.")
}
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
self.count += 1;
if self.count % 100 == 0 {
return Some(Event {
event_type: EventType::Informational,
message: "multiple of 100 events processed".to_string(),
});
}
let pcch_msg = match ie {
InformationElement::LTE(lte_ie) => match &**lte_ie {
LteInformationElement::PCCH(pcch_msg) => pcch_msg,
_ => return None,
},
_ => return None,
};
let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else {
return None;
};
for record in &paging.paging_record_list.as_ref()?.0 {
if let PagingUE_Identity::S_TMSI(_) = record.ue_identity {
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::Low,
},
message: "TMSI was provided to cell".to_string(),
});
}
}
None
}
}

View File

@@ -1,78 +0,0 @@
use axum::body::Body;
use axum::extract::Path;
use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use include_dir::{include_dir, Dir};
use std::sync::Arc;
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender;
use tokio::sync::RwLock;
use tokio_util::io::ReaderStream;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::qmdl_store::RecordingStore;
use crate::{display, DiagDeviceCtrlMessage};
pub struct ServerState {
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub debug_mode: bool,
}
pub async fn get_qmdl(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {}", qmdl_idx),
))?;
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {}", e),
)
})?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
let headers = [
(CONTENT_TYPE, "application/octet-stream"),
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
];
let body = Body::from_stream(qmdl_stream);
Ok((headers, body).into_response())
}
// Bundles the server's static files (html/css/js) into the binary for easy distribution
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/build");
pub async fn serve_static(
State(_): State<Arc<ServerState>>,
Path(path): Path<String>,
) -> impl IntoResponse {
let path = path.trim_start_matches('/');
let mime_type = mime_guess::from_path(path).first_or_text_plain();
match STATIC_DIR.get_file(path) {
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
Some(file) => Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
)
.body(Body::from(file.contents()))
.unwrap(),
}
}

View File

@@ -1,17 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -1,33 +0,0 @@
import prettier from "eslint-config-prettier";
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ["build/", ".svelte-kit/", "dist/"]
}
);

View File

@@ -1,37 +0,0 @@
{
"name": "web",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3",
"vitest": "^2.0.4"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -1,3 +0,0 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities"

13
bin/web/src/app.d.ts vendored
View File

@@ -1,13 +0,0 @@
// See https://svelte.dev/docs/kit/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,45 +0,0 @@
import { describe, it, expect } from 'vitest';
import { EventType, parse_finished_report, Severity, type QualitativeWarning } from './analysis.svelte';
import { parse_ndjson, type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_REPORT_NDJSON: NewlineDeliminatedJson = [
{ "analyzers": [{ "name": "LTE SIB 6/7 Downgrade", "description": "Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities." }, { "name": "IMSI Provided", "description": "Tests whether the UE's IMSI was ever provided to the cell" }, { "name": "Null Cipher", "description": "Tests whether the cell suggests using a null cipher (EEA0)" }, { "name": "Example Analyzer", "description": "Always returns true, if you are seeing this you are either a developer or you are about to have problems." }] },
{ "timestamp": "2024-10-08T13:25:43.011689003-07:00", "skipped_message_reasons": ["DecodingError(UperDecodeError(Error { cause: BufferTooShort, msg: \"PerCodec:DecodeError:Requested Bits to decode 3, Remaining bits 1\", context: [] }))"], "analysis": [] },
{ "timestamp": "2024-10-08T13:25:43.480872496-07:00", "skipped_message_reasons": [], "analysis": [{ "timestamp": "2024-08-19T03:33:54.318Z", "events": [null, null, null, { "event_type": { "type": "QualitativeWarning", "severity": "Low" }, "message": "TMSI was provided to cell" }] }] },
];
describe('analysis report parsing', () => {
it('parses the example analysis', () => {
const report = parse_finished_report(SAMPLE_REPORT_NDJSON);
expect(report.metadata.analyzers).toEqual([
{
"name":"LTE SIB 6/7 Downgrade",
"description":"Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.",
},
{
"name":"IMSI Provided",
"description":"Tests whether the UE's IMSI was ever provided to the cell",
},
{
"name":"Null Cipher",
"description":"Tests whether the cell suggests using a null cipher (EEA0)",
},
{
"name":"Example Analyzer",
"description":"Always returns true, if you are seeing this you are either a developer or you are about to have problems.",
}
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].skipped_message_reasons).toHaveLength(1);
expect(report.rows[0].analysis).toHaveLength(0);
expect(report.rows[1].skipped_message_reasons).toHaveLength(0);
expect(report.rows[1].analysis).toHaveLength(1);
expect(report.rows[1].analysis[0].events).toHaveLength(1);
const event = report.rows[1].analysis[0].events[0];
if (event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
});
});

View File

@@ -1,118 +0,0 @@
import { parse_ndjson, type NewlineDeliminatedJson } from "./ndjson";
import { req } from "./utils.svelte";
export type AnalysisReport = {
metadata: ReportMetadata;
rows: AnalysisRow[];
statistics: ReportStatistics;
};
export type ReportStatistics = {
num_warnings: number;
num_informational_logs: number;
num_skipped_packets: number;
}
export type ReportMetadata = {
analyzers: AnalyzerMetadata[];
rayhunter: RayhunterMetadata;
};
export type RayhunterMetadata = {
rayhunter_version: string;
system_os: string;
arch: string;
};
export type AnalyzerMetadata = {
name: string;
description: string;
};
export type AnalysisRow = {
timestamp: Date;
skipped_message_reasons: string[];
analysis: PacketAnalysis[];
};
export type PacketAnalysis = {
timestamp: Date;
events: Event[];
};
export type Event = QualitativeWarning | InformationalEvent;
export enum EventType {
Informational,
Warning,
}
export type QualitativeWarning = {
type: EventType.Warning;
severity: Severity;
message: string;
};
export enum Severity {
Low,
Medium,
High,
}
export type InformationalEvent = {
type: EventType.Informational;
message: string;
};
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata: ReportMetadata = report_json[0]; // this can be cast directly
let num_warnings = 0;
let num_informational_logs = 0;
let num_skipped_packets = 0;
const rows: AnalysisRow[] = report_json.slice(1).map((row_json: any) => {
const analysis: PacketAnalysis[] = row_json.analysis.map((analysis_json: any) => {
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else if (event_json.event_type === "Informational") {
num_informational_logs += 1;
return {
type: EventType.Informational,
message: event_json.message,
};
} else {
num_warnings += 1;
return {
type: EventType.Warning,
severity: event_json.severity === "High" ? Severity.High :
event_json.severity === "Medium" ? Severity.Medium : Severity.Low,
message: event_json.message,
};
}
})
.filter((maybe_event: Event | null) => maybe_event !== null);
return {
timestamp: analysis_json.timestamp,
events,
};
});
num_skipped_packets += row_json.skipped_message_reasons.length;
return {
timestamp: new Date(row_json.timestamp),
skipped_message_reasons: row_json.skipped_message_reasons,
analysis,
};
});
return {
statistics: {
num_informational_logs,
num_warnings,
num_skipped_packets,
},
metadata,
rows,
};
}
export async function get_report(name: string): Promise<AnalysisReport> {
const report_json = parse_ndjson(await req('GET', `/api/analysis-report/${name}`));
return parse_finished_report(report_json);
}

View File

@@ -1,52 +0,0 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
let { entry, onclick, analysis_visible}: {
entry: ManifestEntry,
onclick: () => void,
analysis_visible: boolean,
} = $props();
let summary = $derived.by(() => {
if (entry.analysis_status === AnalysisStatus.Queued) {
return 'Queued...';
} else if (entry.analysis_status === AnalysisStatus.Running) {
return 'Running...';
} else if (entry.analysis_status === AnalysisStatus.Finished) {
if (entry.analysis_report === undefined) {
return 'Loading...';
} else if (typeof(entry.analysis_report) === 'string') {
return entry.analysis_report;
} else {
let num_warnings = 0;
for (let row of entry.analysis_report.rows) {
for (let analysis of row.analysis) {
for (let event of analysis.events) {
if (event.type === EventType.Warning) {
num_warnings += 1;
}
}
}
}
return `${num_warnings} warnings`;
}
} else {
return 'Loading...';
}
});
let ready = $derived.by(() => {
let finished = entry.analysis_status === AnalysisStatus.Finished;
let report_available = entry.analysis_report !== undefined;
return finished && report_available;
})
let button_class = $derived(ready ? "text-blue-600 border rounded-full px-2" : '');
</script>
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
<span class="{button_class} {entry.get_num_warnings() < 1 ? 'text-green-700 border-green-500 bg-green-200' : 'text-red-700 border-red-500 bg-red-200'}">{summary}</span>
<svg class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
</svg>
</button>

View File

@@ -1,87 +0,0 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow, type AnalysisReport } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
let { report }: {
report: AnalysisReport,
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map();
for (const row of report.rows) {
for (const message of row.skipped_message_reasons) {
let count = map.get(message);
if (count === undefined) {
count = 0;
}
map.set(message, count + 1);
}
}
return map;
});
</script>
<div>
<p class="text-lg underline">Warnings and Informational Logs</p>
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
<p>Nothing to show!</p>
{:else}
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th class="p-2">Timestamp</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row, row_idx}
{#each row.analysis as analysis}
{@const parsed_date = new Date(analysis.timestamp)}
{#each analysis.events.filter(e => e !== null) as event}
<tr class="even:bg-gray-200 odd:bg-white">
{#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][event.severity]}
{@const severity_class = ['bg-red-200', 'bg-red-400', 'bg-red-600'][event.severity]}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {severity_class} text-center">{severity}</td>
{:else if event.type === EventType.Informational}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if}
</tr>
{/each}
{/each}
{/each}
</tbody>
</table>
{/if}
</div>
{#if report.statistics.num_skipped_packets > 0}
<div>
<p class="text-lg underline">Unparsed Messages</p>
<p>These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a problem.</p>
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col" class="p-2">Total Msgs Affected</th>
<th scope="col">Reason/Error</th>
</tr>
</thead>
<tbody>
{#each skipped_messages.entries() as [message, count]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="text-center">{count}</td>
<td>{message}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
import AnalysisTable from "./AnalysisTable.svelte";
let { entry }: {
entry: ManifestEntry,
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
</script>
<div class="container mt-2">
{#if entry.analysis_report === undefined}
<p>Report unavailable, try refreshing.</p>
{:else if typeof(entry.analysis_report) === 'string'}
<p>Error getting analysis report: {entry.analysis_report}</p>
{:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
<div class="flex flex-col gap-2">
{#if entry.analysis_report.rows.length > 0}
<AnalysisTable report={entry.analysis_report} />
{:else}
<p>No warnings to display!</p>
{/if}
{#if metadata !== undefined && metadata.rayhunter !== undefined}
<div>
<p class="text-lg underline">Metadata</p>
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
</div>
<div>
<p class="text-lg underline">Analyzers</p>
{#each metadata.analyzers as analyzer}
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
{/each}
</div>
{:else}
<p>N/A (analysis generated by an older version of rayhunter)</p>
{/if}
</div>
{/if}
</div>

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import { req } from "$lib/utils.svelte";
import DeleteButton from "./DeleteButton.svelte";
function confirmDelete() {
if (window.confirm(`Permanently delete ALL recordings?`)) {
req('POST', '/api/delete-all-recordings')
}
}
</script>
<div class="flex flex-row justify-end gap-2">
<DeleteButton
text="Delete ALL Recordings"
prompt={`Are you sure you want to delete ALL recordings?`}
url={`/api/delete-all-recordings`}
/>
</div>

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import { req } from "$lib/utils.svelte";
let { text, url, prompt }: {
text?: string,
url: string,
prompt: string,
} = $props();
function confirmDelete() {
if (window.confirm(prompt)) {
req('POST', url)
}
}
</script>
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row" onclick={confirmDelete} aria-label="delete">
<p>{text}</p>
<svg
style="width:24px;height:24px"
viewBox="0 0 24 24"
>
<path
fill="white"
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
/>
</svg>
</button>

View File

@@ -1,18 +0,0 @@
<script lang="ts">
let { url, text, full_button=false }: {
url: string;
text: string;
full_button?: boolean;
} = $props();
function download() {
window.location.href = url;
}
</script>
<button class="flex flex-row {full_button ? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md' : 'text-blue-600 underline'}" onclick={download}>
{text}
<svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z"/>
</svg>
</button>

View File

@@ -1,37 +0,0 @@
<script lang="ts">
import { Manifest, ManifestEntry } from "$lib/manifest.svelte";
import TableRow from "./ManifestTableRow.svelte";
import Card from "./ManifestCard.svelte"
interface Props {
entries: ManifestEntry[];
server_is_recording: boolean;
}
let { entries, server_is_recording }: Props = $props();
</script>
<!--For larger screens we use a table-->
<table class="hidden table-auto text-left lg:table">
<thead>
<tr class="bg-gray-100 drop-shadow">
<th class='p-2' scope="col">ID</th>
<th class='p-2' scope="col">Started</th>
<th class='p-2' scope="col">Last Message</th>
<th class='p-2' scope="col">Size</th>
<th class='p-2' scope="col">PCAP</th>
<th class='p-2' scope="col">QMDL</th>
<th class='p-2' scope="col">Analysis</th>
<th class='p-2' scope="col"></th>
</tr>
</thead>
<tbody>
{#each entries as entry, i}
<TableRow {entry} current={false} {i} />
{/each}
</tbody>
</table>
<!--For smaller screens we use cards-->
<div class="lg:hidden flex flex-col gap-4">
{#each entries as entry, i}
<Card {entry} current={false} {i} />
{/each}
</div>

View File

@@ -1,55 +0,0 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from "$lib/components/DeleteButton.svelte";
import AnalysisStatus from "./AnalysisStatus.svelte";
import AnalysisView from "./AnalysisView.svelte";
let { entry, current, i }: {
entry: ManifestEntry;
current: boolean;
i: number;
} = $props();
// passing `undefined` as the locale uses the browser default
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
let alternating_row_color = $derived(i % 2 == 0 ? "bg-white" : "bg-gray-100");
let status_row_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) {
return "bg-red-100";
}
return current ? "bg-green-100" : alternating_row_color
});
let analysis_visible = $state(false);
function toggle_analysis_visibility() {
analysis_visible = !analysis_visible;
}
</script>
<tr class="{status_row_color} drop-shadow">
<td class="p-2">{entry.name}</td>
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
<td class="p-2">{date_formatter.format(entry.last_message_time)}</td>
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></td>
{#if current}
<td class="p-2"></td>
{:else}
<td class="p-2">
<DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()}
/>
</td>
{/if}
</tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="8">
<AnalysisView {entry} />
</td>
</tr>

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import { req } from "$lib/utils.svelte";
let { server_is_recording }: {
server_is_recording: boolean;
} = $props();
let client_set_recording = $state(server_is_recording);
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
async function start_recording() {
await req('POST', '/api/start-recording');
client_set_recording = true;
}
async function stop_recording() {
await req('POST', '/api/stop-recording');
client_set_recording = false;
}
const recording_button_classes = "text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1";
</script>
<div>
{#if waiting_for_server}
<button class={server_is_recording ? stop_recording_classes : start_recording_classes}>
{server_is_recording ? "Stopping..." : "Starting..."}
</button>
{:else if server_is_recording}
<button class="{recording_button_classes} bg-red-500 hover:bg-red-700" onclick={stop_recording}>
<span>Stop</span>
<svg class="w-6 h-6 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z"/>
</svg>
</button>
{:else}
<button class="{recording_button_classes} bg-blue-500 hover:bg-blue-700" onclick={start_recording}>
<span>Start</span>
<svg class="w-6 h-6 text-white" 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="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z" clip-rule="evenodd"/>
</svg>
</button>
{/if}
</div>
<style>
</style>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import { type SystemStats } from "$lib/systemStats";
let { stats }: {
stats: SystemStats;
} = $props();
const table_cell_classes = "border p-1 lg:p-2";
</script>
<div class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100">
<p class="text-xl mb-2">System Information</p>
<table class="table-auto border">
<tbody>
<tr class="border">
<th class={table_cell_classes}>
Rayhunter Version
</th>
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
</tr>
<tr class="border">
<th class={table_cell_classes}>
Storage
</th>
<td class={table_cell_classes}>
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} / {stats.disk_stats.available_size})
</td>
</tr>
<tr class="border-b">
<th class={table_cell_classes}>
Memory (RAM)
</th>
<td class={table_cell_classes}>
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -1,26 +0,0 @@
export interface SystemStats {
disk_stats: DiskStats;
memory_stats: MemoryStats;
runtime_metadata: RuntimeMetadata;
}
export interface RuntimeMetadata {
rayhunter_version: string,
system_os: string,
arch: string,
}
export interface DiskStats {
partition: string,
total_size: string,
used_size: string,
available_size: string,
used_percent: string,
mounted_on: string,
}
export interface MemoryStats {
total: string,
used: string,
free: string,
}

View File

@@ -1,23 +0,0 @@
import { Manifest } from "./manifest.svelte";
import type { SystemStats } from "./systemStats";
export async function req(method: string, url: string): Promise<string> {
const response = await fetch(url, {
method: method,
});
const body = await response.text();
if (response.status >= 200 && response.status < 300) {
return body;
} else {
throw new Error(body);
}
}
export async function get_manifest(): Promise<Manifest> {
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
return new Manifest(manifest_json);
}
export async function get_system_stats(): Promise<SystemStats> {
return JSON.parse(await req('GET', '/api/system-stats'));
}

View File

@@ -1,6 +0,0 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View File

@@ -1,84 +0,0 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import { get_manifest, get_system_stats } from "$lib/utils.svelte";
import ManifestTable from "$lib/components/ManifestTable.svelte";
import Card from "$lib/components/ManifestCard.svelte";
import type { SystemStats } from "$lib/systemStats";
import { AnalysisManager } from "$lib/analysisManager.svelte";
import SystemStatsTable from "$lib/components/SystemStatsTable.svelte";
import DeleteAllButton from "$lib/components/DeleteAllButton.svelte";
import RecordingControls from "$lib/components//RecordingControls.svelte";
let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false);
let recording = $state(false);
let entries: ManifestEntry[] = $state([]);
let current_entry: ManifestEntry | undefined = $state(undefined);
let system_stats: SystemStats | undefined = $state(undefined);
$effect(() => {
const interval = setInterval(async () => {
await manager.update();
let new_manifest = await get_manifest();
await new_manifest.set_analysis_status(manager);
entries = new_manifest.entries;
current_entry = new_manifest.current_entry;
recording = current_entry !== undefined;
system_stats = await get_system_stats();
loaded = true;
}, 1000);
return () => clearInterval(interval);
})
</script>
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
<img src="/rayhunter_text.png" class="h-10 xl:h-12"/>
<div class="flex flex-row gap-4">
<a class="flex flex-row gap-1 group" href="https://github.com/EFForg/rayhunter/issues" target="_blank">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Report Issue</span>
<svg class="w-6 h-6 text-white group-hover:text-gray-400" 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="M12.006 2a9.847 9.847 0 0 0-6.484 2.44 10.32 10.32 0 0 0-3.393 6.17 10.48 10.48 0 0 0 1.317 6.955 10.045 10.045 0 0 0 5.4 4.418c.504.095.683-.223.683-.494 0-.245-.01-1.052-.014-1.908-2.78.62-3.366-1.21-3.366-1.21a2.711 2.711 0 0 0-1.11-1.5c-.907-.637.07-.621.07-.621.317.044.62.163.885.346.266.183.487.426.647.71.135.253.318.476.538.655a2.079 2.079 0 0 0 2.37.196c.045-.52.27-1.006.635-1.37-2.219-.259-4.554-1.138-4.554-5.07a4.022 4.022 0 0 1 1.031-2.75 3.77 3.77 0 0 1 .096-2.713s.839-.275 2.749 1.05a9.26 9.26 0 0 1 5.004 0c1.906-1.325 2.74-1.05 2.74-1.05.37.858.406 1.828.101 2.713a4.017 4.017 0 0 1 1.029 2.75c0 3.939-2.339 4.805-4.564 5.058a2.471 2.471 0 0 1 .679 1.897c0 1.372-.012 2.477-.012 2.814 0 .272.18.592.687.492a10.05 10.05 0 0 0 5.388-4.421 10.473 10.473 0 0 0 1.313-6.948 10.32 10.32 0 0 0-3.39-6.165A9.847 9.847 0 0 0 12.007 2Z" clip-rule="evenodd"/>
</svg>
</a>
<a class="flex flex-row gap-1 group" href="https://efforg.github.io/rayhunter/" target="_blank">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Docs</span>
<svg class="w-6 h-6 text-white group-hover:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v13H7a2 2 0 0 0-2 2Zm0 0a2 2 0 0 0 2 2h12M9 3v14m7 0v4"/>
</svg>
</a>
</div>
</div>
<div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if loaded}
<div class="flex flex-col lg:flex-row gap-4">
{#if recording}
<Card entry={current_entry} current={true} i={0} server_is_recording={recording}/>
{:else}
<div 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">
<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
</span>
<span>Rayhunter is not currently running and will not detect abnormal behavior!</span>
<div class="flex flex-row justify-end mt-2">
<RecordingControls {recording} />
</div>
</div>
{/if}
<SystemStatsTable stats={system_stats!} />
</div>
<div class="flex flex-col gap-2">
<span class="text-xl">History</span>
<ManifestTable entries={entries} server_is_recording={recording} />
</div>
<DeleteAllButton/>
{:else}
<div class="flex flex-col justify-center items-center">
<img src="/rayhunter_orca_only.png" class="h-48 animate-spin"/>
<p class="text-xl">Loading...</p>
</div>
{/if}
</div>

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -1,15 +0,0 @@
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
// default options are shown. On some platforms
// these options are set automatically — see below
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true
})
}
};

View File

@@ -1,17 +0,0 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
'rayhunter-blue': '#4e4eb1',
'rayhunter-dark-blue': '#3f3da0',
'rayhunter-green': '#94ea18'
}
}
},
plugins: []
} as Config;

View File

@@ -1,19 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@@ -3,3 +3,6 @@ authors = ["The Rayhunter Team"]
language = "en"
src = "doc"
title = "Rayhunter - An IMSI Catcher Catcher"
[output.html]
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"

13
check/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "rayhunter-check"
version = "0.10.1"
edition = "2024"
[dependencies]
rayhunter = { path = "../lib" }
futures = { version = "0.3.30", default-features = false }
log = "0.4.20"
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
pcap-file-tokio = "0.1.0"
clap = { version = "4.5.2", features = ["derive"] }
walkdir = "2.5.0"

214
check/src/main.rs Normal file
View File

@@ -0,0 +1,214 @@
use clap::Parser;
use futures::TryStreamExt;
use log::{debug, error, info, warn};
use pcap_file_tokio::pcapng::{Block, PcapNgReader};
use rayhunter::{
analysis::analyzer::{AnalysisRow, AnalyzerConfig, EventType, Harness},
diag::DataType,
gsmtap_parser,
pcap::GsmtapPcapWriter,
qmdl::QmdlReader,
};
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
use tokio::fs::File;
use walkdir::WalkDir;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[arg(short = 'p', long, help = "A file or directory of packet captures")]
path: PathBuf,
#[arg(short = 'P', long, help = "Convert qmdl files to pcap before analysis")]
pcapify: bool,
#[arg(long, help = "Show why some packets were skipped during analysis")]
show_skipped: bool,
#[arg(short, long, help = "Only print warnings/errors to stdout")]
quiet: bool,
#[arg(short, long, help = "Show debug messages")]
debug: bool,
}
#[derive(Default)]
struct Report {
skipped_reasons: HashMap<String, u32>,
total_messages: u32,
warnings: u32,
skipped: u32,
file_path: String,
}
impl Report {
fn new(file_path: &str) -> Self {
Report {
file_path: file_path.to_string(),
..Default::default()
}
}
fn process_row(&mut self, row: AnalysisRow) {
self.total_messages += 1;
if let Some(reason) = row.skipped_message_reason {
*self.skipped_reasons.entry(reason).or_insert(0) += 1;
self.skipped += 1;
return;
}
for maybe_event in row.events {
let Some(event) = maybe_event else { continue };
let Some(timestamp) = row.packet_timestamp else {
continue;
};
match event.event_type {
EventType::Informational => {
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
}
EventType::Low | EventType::Medium | EventType::High => {
warn!(
"{}: WARNING (Severity: {:?}) - {} {}",
self.file_path, event.event_type, timestamp, event.message,
);
self.warnings += 1;
}
}
}
}
fn print_summary(&self, show_skipped: bool) {
if show_skipped && self.skipped > 0 {
info!("{}: messages skipped:", self.file_path);
for (reason, count) in self.skipped_reasons.iter() {
info!(" - {count}: \"{reason}\"");
}
}
info!(
"{}: {} messages analyzed, {} warnings, {} messages skipped",
self.file_path, self.total_messages, self.warnings, self.skipped
);
}
}
async fn analyze_pcap(pcap_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
let pcap_file = &mut File::open(&pcap_path).await.expect("failed to open file");
let mut pcap_reader = PcapNgReader::new(pcap_file)
.await
.expect("failed to read PCAP file");
let mut report = Report::new(pcap_path);
while let Some(Ok(block)) = pcap_reader.next_block().await {
let row = match block {
Block::EnhancedPacket(packet) => harness.analyze_pcap_packet(packet),
other => {
debug!("{pcap_path}: skipping pcap packet {other:?}");
continue;
}
};
report.process_row(row);
}
report.print_summary(show_skipped);
}
async fn analyze_qmdl(qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
let file_size = qmdl_file
.metadata()
.await
.expect("failed to get QMDL file metadata")
.len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin!(
qmdl_reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
);
let mut report = Report::new(qmdl_path);
while let Some(container) = qmdl_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
for row in harness.analyze_qmdl_messages(container) {
report.process_row(row);
}
}
report.print_summary(show_skipped);
}
async fn pcapify(qmdl_path: &PathBuf) {
let qmdl_file = &mut File::open(&qmdl_path)
.await
.expect("failed to open qmdl file");
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
let mut pcap_path = qmdl_path.clone();
pcap_path.set_extension("pcapng");
let pcap_file = &mut File::create(&pcap_path)
.await
.expect("failed to open pcap file");
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
pcap_writer.write_iface_header().await.unwrap();
while let Some(container) = qmdl_reader
.get_next_messages_container()
.await
.expect("failed to get container")
{
for msg in container.into_messages().into_iter().flatten() {
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
pcap_writer
.write_gsmtap_message(parsed, timestamp)
.await
.expect("failed to write");
}
}
}
info!("wrote pcap to {:?}", &pcap_path);
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let level = if args.debug {
log::LevelFilter::Debug
} else if args.quiet {
log::LevelFilter::Warn
} else {
log::LevelFilter::Info
};
rayhunter::init_logging(level);
let harness = Harness::new_with_config(&AnalyzerConfig::default());
info!("Analyzers:");
for analyzer in harness.get_metadata().analyzers {
info!(
" - {} (v{}): {}",
analyzer.name, analyzer.version, analyzer.description
);
}
for maybe_entry in WalkDir::new(&args.path) {
let Ok(entry) = maybe_entry else {
error!("failed to open dir entry {maybe_entry:?}");
continue;
};
let name = entry.file_name();
let name_str = name.to_str().unwrap();
let path = entry.path();
let path_str = path.to_str().unwrap();
// instead of relying on the QMDL extension, can we check if a file is
// QMDL by inspecting the contents?
if name_str.ends_with(".qmdl") {
info!("**** Beginning analysis of {name_str}");
analyze_qmdl(path_str, args.show_skipped).await;
if args.pcapify {
pcapify(&path.to_path_buf()).await;
}
} else if name_str.ends_with(".pcap") || name_str.ends_with(".pcapng") {
// TODO: if we've already analyzed a QMDL, skip its corresponding pcap
info!("**** Beginning analysis of {name_str}");
analyze_pcap(path_str, args.show_skipped).await;
}
}
}

View File

@@ -1,42 +1,34 @@
[package]
name = "rayhunter-daemon"
version = "0.3.2"
edition = "2021"
version = "0.10.1"
edition = "2024"
rust-version = "1.88.0"
[features]
# These feature flags are mutually exclusive, and exactly one must be enabled.
orbic = ["rayhunter/orbic"]
tplink = ["rayhunter/tplink"]
default = ["orbic"]
[[bin]]
name = "rayhunter-daemon"
path = "src/daemon.rs"
[[bin]]
name = "rayhunter-check"
path = "src/check.rs"
default = ["rustcrypto-tls"]
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
ring-tls = ["reqwest/rustls-tls-webpki-roots"]
[dependencies]
rayhunter = { path = "../lib" }
toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] }
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
thiserror = "1.0.52"
libc = "0.2.150"
log = "0.4.20"
env_logger = { version = "0.11", default-features = false }
tokio-util = { version = "0.7.10", features = ["rt", "io"] }
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
futures-macro = "0.3.30"
include_dir = "0.7.3"
mime_guess = "2.0.4"
chrono = { version = "0.4.31", features = ["serde"] }
tokio-stream = { version = "0.1.14", default-features = false }
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
futures = { version = "0.3.30", default-features = false }
clap = { version = "4.5.2", features = ["derive"] }
serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1"
simple_logger = "5.0.0"
async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
async-trait = "0.1.88"

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::{future, pin};
use std::{cmp, future, pin};
use axum::Json;
use axum::{
@@ -7,8 +7,8 @@ use axum::{
http::StatusCode,
};
use futures::TryStreamExt;
use log::{debug, error, info};
use rayhunter::analysis::analyzer::Harness;
use log::{error, info};
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader;
use serde::Serialize;
@@ -18,14 +18,12 @@ use tokio::sync::mpsc::Receiver;
use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio_util::task::TaskTracker;
use crate::dummy_analyzer::TestAnalyzer;
use crate::qmdl_store::RecordingStore;
use crate::server::ServerState;
pub struct AnalysisWriter {
writer: BufWriter<File>,
harness: Harness,
bytes_written: usize,
}
// We write our analysis results to a file immediately to minimize the amount of
@@ -35,15 +33,11 @@ pub struct AnalysisWriter {
// lets us simply append new rows to the end without parsing the entire JSON
// object beforehand.
impl AnalysisWriter {
pub async fn new(file: File, enable_dummy_analyzer: bool) -> Result<Self, std::io::Error> {
let mut harness = Harness::new_with_all_analyzers();
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
}
pub async fn new(file: File, analyzer_config: &AnalyzerConfig) -> Result<Self, std::io::Error> {
let harness = Harness::new_with_config(analyzer_config);
let mut result = Self {
writer: BufWriter::new(file),
bytes_written: 0,
harness,
};
let metadata = result.harness.get_metadata();
@@ -52,22 +46,25 @@ impl AnalysisWriter {
}
// Runs the analysis harness on the given container, serializing the results
// to the analysis file and returning the file's new length.
// to the analysis file, returning the whether any warnings were detected
pub async fn analyze(
&mut self,
container: MessagesContainer,
) -> Result<(usize, bool), std::io::Error> {
let row = self.harness.analyze_qmdl_messages(container);
if !row.is_empty() {
self.write(&row).await?;
) -> Result<EventType, std::io::Error> {
let mut max_type = EventType::Informational;
for row in self.harness.analyze_qmdl_messages(container) {
if !row.is_empty() {
self.write(&row).await?;
}
max_type = cmp::max(max_type, row.get_max_event_type());
}
Ok((self.bytes_written, row.contains_warnings()))
Ok(max_type)
}
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
let mut value_str = serde_json::to_string(value).unwrap();
value_str.push('\n');
self.bytes_written += value_str.len();
self.writer.write_all(value_str.as_bytes()).await?;
self.writer.flush().await?;
Ok(())
@@ -130,62 +127,58 @@ async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus
async fn perform_analysis(
name: &str,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig,
) -> Result<(), String> {
info!("Opening QMDL and analysis file for {}...", name);
let (analysis_file, qmdl_file, entry_index) = {
info!("Opening QMDL and analysis file for {name}...");
let (analysis_file, qmdl_file) = {
let mut qmdl_store = qmdl_store_lock.write().await;
let (entry_index, _) = qmdl_store
.entry_for_name(name)
.ok_or(format!("failed to find QMDL store entry for {}", name))?;
.ok_or(format!("failed to find QMDL store entry for {name}"))?;
let analysis_file = qmdl_store
.clear_and_open_entry_analysis(entry_index)
.await
.map_err(|e| format!("{:?}", e))?;
.map_err(|e| format!("{e:?}"))?;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| format!("{:?}", e))?;
.map_err(|e| format!("{e:?}"))?;
(analysis_file, qmdl_file, entry_index)
(analysis_file, qmdl_file)
};
let mut analysis_writer = AnalysisWriter::new(analysis_file, enable_dummy_analyzer)
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
.await
.map_err(|e| format!("{:?}", e))?;
.map_err(|e| format!("{e:?}"))?;
let file_size = qmdl_file
.metadata()
.await
.expect("failed to get QMDL file metadata")
.len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin::pin!(qmdl_reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
let mut qmdl_stream = pin::pin!(
qmdl_reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
);
info!("Starting analysis for {}...", name);
info!("Starting analysis for {name}...");
while let Some(container) = qmdl_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
let (size_bytes, _) = analysis_writer
let _ = analysis_writer
.analyze(container)
.await
.map_err(|e| format!("{:?}", e))?;
debug!("{} analysis: {} bytes written", name, size_bytes);
let mut qmdl_store = qmdl_store_lock.write().await;
qmdl_store
.update_entry_analysis_size(entry_index, size_bytes)
.await
.map_err(|e| format!("{:?}", e))?;
.map_err(|e| format!("{e:?}"))?;
}
analysis_writer
.close()
.await
.map_err(|e| format!("{:?}", e))?;
info!("Analysis for {} complete!", name);
.map_err(|e| format!("{e:?}"))?;
info!("Analysis for {name} complete!");
Ok(())
}
@@ -195,7 +188,7 @@ pub fn run_analysis_thread(
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig,
) {
task_tracker.spawn(async move {
loop {
@@ -205,10 +198,9 @@ pub fn run_analysis_thread(
for _ in 0..count {
let name = dequeue_to_running(analysis_status_lock.clone()).await;
if let Err(err) =
perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer)
.await
perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
{
error!("failed to analyze {}: {}", name, err);
error!("failed to analyze {name}: {err}");
}
finish_running_analysis(analysis_status_lock.clone()).await;
}
@@ -269,7 +261,7 @@ pub async fn start_analysis(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to queue new analysis files: {:?}", e),
format!("failed to queue new analysis files: {e:?}"),
)
})?;
}

117
daemon/src/battery/mod.rs Normal file
View File

@@ -0,0 +1,117 @@
use std::{path::Path, time::Duration};
use log::{info, warn};
use rayhunter::Device;
use serde::Serialize;
use tokio::select;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{
error::RayhunterError,
notifications::{Notification, NotificationType},
};
pub mod orbic;
pub mod tmobile;
pub mod tplink;
pub mod wingtech;
const LOW_BATTERY_LEVEL: u8 = 10;
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
pub struct BatteryState {
level: u8,
is_plugged_in: bool,
}
async fn is_plugged_in_from_file(path: &Path) -> Result<bool, RayhunterError> {
match tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('0') => Ok(false),
Some('1') => Ok(true),
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
}
}
async fn get_level_from_percentage_file(path: &Path) -> Result<u8, RayhunterError> {
tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.trim_end()
.parse()
.or(Err(RayhunterError::BatteryLevelParseError))
}
pub async fn get_battery_status(device: &Device) -> Result<BatteryState, RayhunterError> {
Ok(match device {
Device::Orbic => orbic::get_battery_state().await?,
Device::Wingtech => wingtech::get_battery_state().await?,
Device::Tmobile => tmobile::get_battery_state().await?,
Device::Tplink => tplink::get_battery_state().await?,
_ => return Err(RayhunterError::FunctionNotSupportedForDeviceError),
})
}
pub fn run_battery_notification_worker(
task_tracker: &TaskTracker,
device: Device,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
shutdown_token: CancellationToken,
) {
task_tracker.spawn(async move {
// Don't send a notification initially if the device starts at a low battery level.
let mut triggered = match get_battery_status(&device).await {
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
info!("Battery status not supported for this device, disabling battery notifications");
return;
}
Err(e) => {
warn!("Failed to get battery status: {e}");
true
}
Ok(status) => status.level <= LOW_BATTERY_LEVEL,
};
loop {
select! {
_ = shutdown_token.cancelled() => break,
_ = tokio::time::sleep(Duration::from_secs(15)) => {}
}
let status = match get_battery_status(&device).await {
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
info!("Battery status not supported for this device, disabling battery notifications");
break;
}
Err(e) => {
warn!("Failed to get battery status: {e}");
continue;
}
Ok(status) => status,
};
// To avoid flapping, if the notification has already been triggered
// wait until the device has been plugged in and the battery level
// is high enough to re-enable notifications.
if triggered && status.is_plugged_in && status.level > LOW_BATTERY_LEVEL {
triggered = false;
continue;
}
if !triggered && !status.is_plugged_in && status.level <= LOW_BATTERY_LEVEL {
notification_channel
.send(Notification::new(
NotificationType::LowBattery,
"Rayhunter's battery is low".to_string(),
None,
))
.await
.expect("Failed to send to notification channel");
triggered = true;
}
}
});
}

View File

@@ -0,0 +1,28 @@
use std::path::Path;
use crate::{
battery::{BatteryState, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level";
const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('1') => Ok(10),
Some('2') => Ok(25),
Some('3') => Ok(50),
Some('4') => Ok(75),
Some('5') => Ok(100),
_ => Err(RayhunterError::BatteryLevelParseError),
}?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}

View File

@@ -0,0 +1,16 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}

View File

@@ -0,0 +1,39 @@
use crate::{battery::BatteryState, error::RayhunterError};
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
let uci_battery = tokio::process::Command::new("uci")
.arg("get")
.arg("battery.battery_mgr.power_level")
.output()
.await?;
let uci_plugged_in = tokio::process::Command::new("uci")
.arg("get")
.arg("battery.battery_mgr.is_charging")
.output()
.await?;
if !uci_battery.status.success() {
return Err(RayhunterError::BatteryLevelParseError);
}
if !uci_plugged_in.status.success() {
return Err(RayhunterError::BatteryPluggedInStatusParseError);
}
let uci_battery = String::from_utf8_lossy(&uci_battery.stdout)
.trim_end()
.parse()
.map_err(|_| RayhunterError::BatteryLevelParseError)?;
let uci_plugged_in = match String::from_utf8_lossy(&uci_plugged_in.stdout).trim_end() {
"0" => Ok(false),
"1" => Ok(true),
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
}?;
Ok(BatteryState {
level: uci_battery,
is_plugged_in: uci_plugged_in,
})
}

View File

@@ -0,0 +1,17 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str =
"/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}

View File

@@ -1,16 +1,25 @@
use log::warn;
use serde::{Deserialize, Serialize};
use rayhunter::Device;
use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
use crate::notifications::NotificationType;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
pub qmdl_store_path: String,
pub port: u16,
pub debug_mode: bool,
pub device: Device,
pub ui_level: u8,
pub enable_dummy_analyzer: bool,
pub colorblind_mode: bool,
pub key_input_mode: u8,
pub ntfy_url: Option<String>,
pub enabled_notifications: Vec<NotificationType>,
pub analyzers: AnalyzerConfig,
}
impl Default for Config {
@@ -19,20 +28,25 @@ impl Default for Config {
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
port: 8080,
debug_mode: false,
device: Device::Orbic,
ui_level: 1,
enable_dummy_analyzer: false,
colorblind_mode: false,
key_input_mode: 0,
analyzers: AnalyzerConfig::default(),
ntfy_url: None,
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
}
}
}
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
where
P: AsRef<std::path::Path>,
{
if let Ok(config_file) = std::fs::read_to_string(&path) {
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
} else {
warn!("unable to read config file, using default config");
Ok(Config::default())
}
}

452
daemon/src/diag.rs Normal file
View File

@@ -0,0 +1,452 @@
use std::ops::DerefMut;
use std::pin::pin;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt, future};
use log::{debug, error, info, warn};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream;
use tokio_util::task::TaskTracker;
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display;
use crate::notifications::{Notification, NotificationType};
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording,
DeleteEntry {
name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
DeleteAllEntries {
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
Exit,
}
pub struct DiagTask {
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
state: DiagState,
max_type_seen: EventType,
}
enum DiagState {
Recording {
qmdl_writer: QmdlWriter<File>,
analysis_writer: Box<AnalysisWriter>,
},
Stopped,
}
impl DiagTask {
fn new(
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
state: DiagState::Stopped,
max_type_seen: EventType::Informational,
}
}
/// Start recording
async fn start(&mut self, qmdl_store: &mut RecordingStore) {
self.max_type_seen = EventType::Informational;
let (qmdl_file, analysis_file) = qmdl_store
.new_entry()
.await
.expect("failed creating QMDL file entry");
self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
.await
.map(Box::new)
.expect("failed to write to analysis file");
self.state = DiagState::Recording {
qmdl_writer,
analysis_writer,
};
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Recording)
.await
{
warn!("couldn't send ui update message: {e}");
}
}
/// Stop recording
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
self.stop_current_recording().await;
if let Some((_, entry)) = qmdl_store.get_current_entry()
&& let Err(e) = self
.analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await
{
warn!("couldn't send analysis message: {e}");
}
if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {e}");
}
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Paused)
.await
{
warn!("couldn't send ui update message: {e}");
}
}
async fn delete_entry(
&mut self,
qmdl_store: &mut RecordingStore,
name: &str,
) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store).await;
}
let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entry {e}");
}
res
}
async fn delete_all_entries(
&mut self,
qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store).await;
let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}");
}
res
}
async fn stop_current_recording(&mut self) {
let mut state = DiagState::Stopped;
std::mem::swap(&mut self.state, &mut state);
if let DiagState::Recording {
analysis_writer, ..
} = state
{
analysis_writer
.close()
.await
.expect("failed to close analysis writer");
}
}
async fn process_container(
&mut self,
qmdl_store: &mut RecordingStore,
container: MessagesContainer,
) {
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
return;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let DiagState::Recording {
qmdl_writer,
analysis_writer,
} = &mut self.state
{
qmdl_writer
.write_container(&container)
.await
.expect("failed to write to QMDL writer");
debug!(
"total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written
);
let index = qmdl_store
.current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written)
.await
.expect("failed to update qmdl file size");
debug!("done!");
let max_type = analysis_writer
.analyze(container)
.await
.expect("failed to analyze container");
if max_type > EventType::Informational {
info!("a heuristic triggered on this run!");
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Rayhunter has detected a {:?} severity event", max_type),
Some(Duration::from_secs(60 * 5)),
))
.await
.expect("Failed to send to notification channel");
}
if max_type > self.max_type_seen {
self.max_type_seen = max_type;
if self.max_type_seen > EventType::Informational {
self.ui_update_sender
.send(display::DisplayState::WarningDetected {
event_type: self.max_type_seen,
})
.await
.expect("couldn't send ui update message: {}");
}
}
} else {
debug!("no qmdl_writer set, continuing...");
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
) {
task_tracker.spawn(async move {
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);
qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.unwrap();
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.start(qmdl_store.deref_mut()).await;
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.stop(qmdl_store.deref_mut()).await;
},
// None means all the Senders have been dropped, so it's
// time to go
Some(DiagDeviceCtrlMessage::Exit) | None => {
info!("Diag reader thread exiting...");
diag_task.stop_current_recording().await;
return Ok(())
},
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_entry(qmdl_store.deref_mut(), name.as_str()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete entry respons, receiver dropped");
}
},
Some(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_all_entries(qmdl_store.deref_mut()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete all entries respons, receiver dropped");
}
},
}
}
maybe_container = diag_stream.next() => {
match maybe_container.unwrap() {
Ok(container) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.process_container(qmdl_store.deref_mut(), container).await
},
Err(err) => {
error!("error reading diag device: {err}");
return Err(err);
}
}
}
}
}
});
}
/// Start recording API for web thread
pub async fn start_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send start recording message: {e}"),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
/// Stop recording API for web thread
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {e}"),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn delete_recording(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteEntry {
name: qmdl_name.clone(),
response_tx,
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete entry message: {e}"),
)
})?;
match response_rx.await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete response: {e}"),
)
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Err(RecordingStoreError::NoSuchEntryError) => Err((
StatusCode::BAD_REQUEST,
format!("no recording with name {qmdl_name}"),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"),
)),
}
}
pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx })
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete all entries message: {e}"),
)
})?;
match response_rx.await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete all response: {e}"),
)
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recordings: {e}"),
)),
}
}
pub async fn get_analysis_report(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, _) = if qmdl_name == "live" {
qmdl_store.get_current_entry().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
))?
} else {
qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND,
format!("Couldn't find QMDL entry with name \"{qmdl_name}\""),
))?
};
let analysis_file = qmdl_store
.open_entry_analysis(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
// Read and normalize the NDJSON file
let reader = BufReader::new(analysis_file);
let lines_stream = LinesStream::new(reader.lines());
let mut normalizer = AnalysisLineNormalizer::new();
let normalized_stream = lines_stream
.try_filter(|line| future::ready(!line.is_empty()))
.map_ok(move |line| normalizer.normalize_line(line));
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
let body = Body::from_stream(normalized_stream);
Ok((headers, body).into_response())
}

View File

@@ -0,0 +1,242 @@
use async_trait::async_trait;
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType};
use std::io::Cursor;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
use rayhunter::analysis::analyzer::EventType;
use log::{error, info};
use tokio::sync::mpsc::Receiver;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use include_dir::{Dir, include_dir};
const REFRESH_RATE: u64 = 1000; //how often in milliseconds to refresh the display
#[derive(Copy, Clone)]
pub struct Dimensions {
pub height: u32,
pub width: u32,
}
#[derive(Copy, Clone)]
pub enum LinePattern {
Solid,
Dashed, // _ _ _ _
Dotted, // . . . .
}
#[allow(dead_code)]
#[derive(Copy, Clone)]
pub enum Color {
Red,
Green,
Blue,
White,
Black,
Cyan,
Yellow,
Pink,
Orange,
}
impl Color {
fn rgb(self) -> (u8, u8, u8) {
match self {
Color::Red => (0xff, 0, 0),
Color::Green => (0, 0xff, 0),
Color::Blue => (0, 0, 0xff),
Color::White => (0xff, 0xff, 0xff),
Color::Black => (0, 0, 0),
Color::Cyan => (0, 0xff, 0xff),
Color::Yellow => (0xff, 0xff, 0),
Color::Pink => (0xfe, 0x24, 0xff),
Color::Orange => (0xff, 0xa5, 0),
}
}
}
fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) {
match state {
DisplayState::Paused => (Color::White, LinePattern::Solid),
DisplayState::Recording => {
if colorblind_mode {
(Color::Blue, LinePattern::Solid)
} else {
(Color::Green, LinePattern::Solid)
}
}
DisplayState::WarningDetected { event_type } => match event_type {
EventType::Informational => {
if colorblind_mode {
(Color::Blue, LinePattern::Solid)
} else {
(Color::Green, LinePattern::Solid)
}
}
EventType::Low => (Color::Yellow, LinePattern::Dotted),
EventType::Medium => (Color::Orange, LinePattern::Dashed),
EventType::High => (Color::Red, LinePattern::Solid),
},
}
}
#[async_trait]
pub trait GenericFramebuffer: Send + 'static {
fn dimensions(&self) -> Dimensions;
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>); // rgb, row-wise, left-to-right, top-to-bottom
async fn write_dynamic_image(&mut self, img: DynamicImage) {
let dimensions = self.dimensions();
let mut width = img.width();
let mut height = img.height();
let resized_img: DynamicImage;
if height > dimensions.height || width > dimensions.width {
resized_img = img.resize(dimensions.width, dimensions.height, FilterType::CatmullRom);
width = dimensions.width.min(resized_img.width());
height = dimensions.height.min(resized_img.height());
} else {
resized_img = img;
}
let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::new();
for y in 0..height {
for x in 0..width {
let px = img_rgba8.get_pixel(x, y);
buf.push((px[0], px[1], px[2]));
}
}
self.write_buffer(buf).await
}
async fn draw_gif(&mut self, img_buffer: &[u8]) {
let cursor = Cursor::new(img_buffer);
if let Ok(decoder) = GifDecoder::new(cursor) {
let frames: Vec<_> = decoder
.into_frames()
.filter_map(|f| f.ok())
.map(|frame| {
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
(img, numerator as u64)
})
.collect();
for (img, delay_ms) in frames {
self.write_dynamic_image(img).await;
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
}
}
async fn draw_img(&mut self, img_buffer: &[u8]) {
let img = image::load_from_memory(img_buffer).unwrap();
self.write_dynamic_image(img).await
}
async fn draw_line(&mut self, color: Color, height: u32) {
self.draw_patterned_line(color, height, LinePattern::Solid)
.await
}
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
let width = self.dimensions().width;
let mut buffer = Vec::new();
for _row in 0..height {
for col in 0..width {
let should_draw = match pattern {
LinePattern::Solid => true,
LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off
LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off
};
if should_draw {
buffer.push(color.rgb());
} else {
buffer.push((0, 0, 0)); // Black background
}
}
}
self.write_buffer(buffer).await
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut fb: impl GenericFramebuffer,
shutdown_token: CancellationToken,
mut ui_update_rx: Receiver<DisplayState>,
) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
return;
}
let colorblind_mode = config.colorblind_mode;
let mut display_style = display_style_from_state(DisplayState::Recording, colorblind_mode);
task_tracker.spawn(async move {
// this feels wrong, is there a more rusty way to do this?
let mut img: Option<&[u8]> = None;
if display_level == 2 {
img = Some(
IMAGE_DIR
.get_file("orca.gif")
.expect("failed to read orca.gif")
.contents(),
);
} else if display_level == 3 {
img = Some(
IMAGE_DIR
.get_file("eff.png")
.expect("failed to read eff.png")
.contents(),
);
}
loop {
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
}
match ui_update_rx.try_recv() {
Ok(state) => {
display_style = display_style_from_state(state, colorblind_mode);
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving framebuffer update message: {e}"),
}
let mut status_bar_height = 2;
match display_level {
2 => fb.draw_gif(img.unwrap()).await,
3 => fb.draw_img(img.unwrap()).await,
4 => {
status_bar_height = fb.dimensions().height;
}
128 => {
fb.draw_line(Color::Cyan, 128).await;
fb.draw_line(Color::Pink, 102).await;
fb.draw_line(Color::White, 76).await;
fb.draw_line(Color::Pink, 50).await;
fb.draw_line(Color::Cyan, 25).await;
}
// this branch is for ui_level 1, which is also the default if an
// unknown value is used
_ => {}
};
let (color, pattern) = display_style;
fb.draw_patterned_line(color, status_bar_height, pattern)
.await;
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
}
});
}

View File

@@ -0,0 +1,16 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::DisplayState;
pub fn update_ui(
_task_tracker: &TaskTracker,
_config: &config::Config,
_shutdown_token: CancellationToken,
_ui_update_rx: Receiver<DisplayState>,
) {
info!("Headless mode, not spawning UI.");
}

26
daemon/src/display/mod.rs Normal file
View File

@@ -0,0 +1,26 @@
use rayhunter::analysis::analyzer::EventType;
use serde::{Deserialize, Serialize};
mod generic_framebuffer;
pub mod headless;
pub mod orbic;
pub mod tmobile;
pub mod tplink;
pub mod tplink_framebuffer;
pub mod tplink_onebit;
pub mod uz801;
pub mod wingtech;
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum DisplayState {
/// We're recording but no warning has been found yet.
Recording,
/// We're not recording.
Paused,
/// A non-informational event has been detected.
///
/// Note that EventType::Informational is never sent through this. If it is, it's the same as
/// Recording
WarningDetected { event_type: EventType },
}

View File

@@ -1,9 +1,10 @@
use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use async_trait::async_trait;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
@@ -11,6 +12,7 @@ const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
@@ -20,30 +22,30 @@ impl GenericFramebuffer for Framebuffer {
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
std::fs::write(FB_PATH, &raw_buffer).unwrap();
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
shutdown_token: CancellationToken,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
ui_shutdown_rx,
shutdown_token,
ui_update_rx,
)
}

View File

@@ -0,0 +1,77 @@
/// Display module for Tmobile TMOHS1, blink LEDs on the front of the device.
/// DisplayState::Recording => Signal LED slowly blinks blue.
/// DisplayState::Paused => WiFi LED blinks white.
/// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
macro_rules! led {
($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }};
}
async fn start_blinking(path: String) {
tokio::fs::write(&path, "1").await.ok();
}
async fn stop_blinking(path: String) {
tokio::fs::write(&path, "0").await.ok();
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
info!("Invisible mode, not spawning UI.");
invisible = true;
}
task_tracker.spawn(async move {
let mut state = DisplayState::Recording;
let mut last_state = DisplayState::Paused;
loop {
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
}
match ui_update_rx.try_recv() {
Ok(new_state) => state = new_state,
Err(mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving ui update message: {e}"),
};
if invisible || state == last_state {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
match state {
DisplayState::Paused => {
stop_blinking(led!("signal_blue")).await;
stop_blinking(led!("signal_red")).await;
start_blinking(led!("wlan_white")).await;
}
DisplayState::Recording => {
stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_red")).await;
start_blinking(led!("signal_blue")).await;
}
DisplayState::WarningDetected { .. } => {
stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_blue")).await;
start_blinking(led!("signal_red")).await;
}
}
last_state = state;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}

View File

@@ -1,17 +1,17 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::{tplink_framebuffer, tplink_onebit, DisplayState};
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
use std::fs;
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
shutdown_token: CancellationToken,
ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
@@ -19,11 +19,13 @@ pub fn update_ui(
info!("Invisible mode, not spawning UI.");
}
// Since this is a one-time check at startup, using sync is acceptable
// The alternative would be to make the entire initialization async
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
info!("detected one-bit display");
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
tplink_onebit::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
} else {
info!("fallback to framebuffer");
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
tplink_framebuffer::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
}
}

View File

@@ -1,13 +1,14 @@
use std::fs::File;
use std::io::Write;
use async_trait::async_trait;
use std::os::fd::AsRawFd;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use tokio_util::sync::CancellationToken;
use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
@@ -24,6 +25,7 @@ struct fb_fillrect {
rop: u32,
}
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
@@ -33,12 +35,12 @@ impl GenericFramebuffer for Framebuffer {
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
let dimensions = self.dimensions();
let width = dimensions.width;
let height = buffer.len() as u32 / width;
let mut f = File::options().write(true).open(FB_PATH).unwrap();
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap();
let mut arg = fb_fillrect {
dx: 0,
dy: 0,
@@ -50,15 +52,16 @@ impl GenericFramebuffer for Framebuffer {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
// note: big-endian!
raw_buffer.extend(rgb565.to_be_bytes());
}
f.write_all(&raw_buffer).unwrap();
f.write_all(&raw_buffer).await.unwrap();
// ioctl is a synchronous operation, but it's fast enough that it shouldn't block
unsafe {
let res = libc::ioctl(
f.as_raw_fd(),
@@ -68,7 +71,7 @@ impl GenericFramebuffer for Framebuffer {
);
if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res);
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
}
}
}
@@ -77,14 +80,14 @@ impl GenericFramebuffer for Framebuffer {
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
shutdown_token: CancellationToken,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
ui_shutdown_rx,
shutdown_token,
ui_update_rx,
)
}

View File

@@ -6,12 +6,9 @@ use crate::display::DisplayState;
use log::{error, info};
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use std::fs;
use std::thread::sleep;
use std::time::Duration;
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
@@ -114,7 +111,7 @@ const STATUS_WARNING: &[u8] = pixelart! {
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut ui_shutdown_rx: oneshot::Receiver<()>,
shutdown_token: CancellationToken,
mut ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
@@ -122,23 +119,19 @@ pub fn update_ui(
info!("Invisible mode, not spawning UI.");
}
task_tracker.spawn_blocking(move || {
task_tracker.spawn(async move {
let mut pixels = STATUS_SMILING;
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
}
match ui_update_rx.try_recv() {
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => {
error!("error receiving framebuffer update message: {e}");
@@ -147,13 +140,13 @@ pub fn update_ui(
// we write the status every second because it may have been overwritten through menu
// navigation.
if display_level != 0 {
if let Err(e) = fs::write(OLED_PATH, &pixels) {
error!("failed to write to display: {e}");
}
if display_level != 0
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
{
error!("failed to write to display: {e}");
}
sleep(Duration::from_millis(1000));
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}

View File

@@ -0,0 +1,85 @@
/// Display module for Uz801, light LEDs on the front of the device.
/// DisplayState::Recording => Green LED is solid.
/// DisplayState::Paused => Signal LED is solid blue (wifi LED).
/// DisplayState::WarningDetected => Signal LED is solid red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
macro_rules! led {
($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }};
}
async fn led_on(path: String) {
tokio::fs::write(&path, "1").await.ok();
}
async fn led_off(path: String) {
tokio::fs::write(&path, "0").await.ok();
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
info!("Invisible mode, not spawning UI.");
invisible = true;
}
task_tracker.spawn(async move {
let mut state = DisplayState::Recording;
let mut last_state = DisplayState::Paused;
let mut last_update = std::time::Instant::now();
loop {
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
}
match ui_update_rx.try_recv() {
Ok(new_state) => state = new_state,
Err(mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving ui update message: {e}"),
};
// Update LEDs if state changed or if 5 seconds have passed since last update
let now = std::time::Instant::now();
let should_update = !invisible
&& (state != last_state
|| now.duration_since(last_update) >= Duration::from_secs(5));
if should_update {
match state {
DisplayState::Paused => {
led_off(led!("red")).await;
led_off(led!("green")).await;
led_on(led!("wifi")).await;
}
DisplayState::Recording => {
led_off(led!("red")).await;
led_off(led!("wifi")).await;
led_on(led!("green")).await;
}
DisplayState::WarningDetected { .. } => {
led_off(led!("green")).await;
led_off(led!("wifi")).await;
led_on(led!("red")).await;
}
}
last_state = state;
last_update = now;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}

View File

@@ -0,0 +1,56 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
/// Display support for the Wingtech CT2MHS01 hotspot.
///
/// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
/// WT_HARDWARE_VERSION=89323_1_20
use async_trait::async_trait;
use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
Dimensions {
height: 128,
width: 160,
}
}
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
shutdown_token,
ui_update_rx,
)
}

View File

@@ -15,4 +15,10 @@ pub enum RayhunterError {
QmdlStoreError(#[from] RecordingStoreError),
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
NoStoreDebugMode(String),
#[error("Error parsing file to determine battery level")]
BatteryLevelParseError,
#[error("Error parsing file to determine whether device is plugged in")]
BatteryPluggedInStatusParseError,
#[error("The requested functionality is not supported for this device")]
FunctionNotSupportedForDeviceError,
}

131
daemon/src/key_input.rs Normal file
View File

@@ -0,0 +1,131 @@
use log::{error, info};
use std::time::{Duration, Instant};
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::diag::DiagDeviceCtrlMessage;
#[derive(Debug)]
enum Event {
KeyDown,
KeyUp,
}
const INPUT_EVENT_SIZE: usize = 32;
pub fn run_key_input_thread(
task_tracker: &TaskTracker,
config: &config::Config,
diag_tx: Sender<DiagDeviceCtrlMessage>,
cancellation_token: CancellationToken,
) {
if config.key_input_mode == 0 {
return;
}
task_tracker.spawn(async move {
// Open the input device
let mut file = match File::open("/dev/input/event0").await {
Ok(file) => file,
Err(e) => {
error!("Failed to open /dev/input/event0: {e}");
return;
}
};
let mut buffer = [0u8; INPUT_EVENT_SIZE];
let mut last_keyup: Option<Instant> = None;
let mut last_event_time: Option<Instant> = None;
loop {
tokio::select! {
_ = cancellation_token.cancelled() => {
info!("received key input shutdown");
return;
}
result = file.read_exact(&mut buffer) => {
if let Err(e) = result {
error!("failed to read key input: {e}");
return;
}
}
}
let event = parse_event(buffer);
let now = Instant::now();
// On orbic it was observed that pressing the power button can trigger many successive
// events. Drop events that are too close together.
if let Some(last_time) = last_event_time
&& now.duration_since(last_time) < Duration::from_millis(50)
{
last_event_time = Some(now);
continue;
}
last_event_time = Some(now);
match event {
Event::KeyUp => {
if let Some(last_keyup_instant) = last_keyup {
let elapsed = now.duration_since(last_keyup_instant);
if elapsed >= Duration::from_millis(100)
&& elapsed <= Duration::from_millis(800)
{
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await
{
error!("Failed to send StopRecording: {e}");
}
if let Err(e) =
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
{
error!("Failed to send StartRecording: {e}");
}
last_keyup = None;
continue;
}
}
last_keyup = Some(now);
}
Event::KeyDown => {}
}
}
});
}
fn parse_event(input: [u8; INPUT_EVENT_SIZE]) -> Event {
if input[12] == 0 {
Event::KeyUp
} else {
Event::KeyDown
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_event_keydown_m7350_v5() {
let input = [
0x57, 0x6c, 0x09, 0x00, 0x7c, 0xfb, 0x03, 0x00, 0x01, 0x00, 0x74, 0x00, 0x01, 0x00,
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
assert!(matches!(parse_event(input), Event::KeyDown));
}
#[test]
fn test_parse_event_keyup_m7350_v5() {
let input = [
0x57, 0x6c, 0x09, 0x00, 0x1b, 0x15, 0x05, 0x00, 0x01, 0x00, 0x74, 0x00, 0x00, 0x00,
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
assert!(matches!(parse_event(input), Event::KeyUp));
}
}

View File

@@ -1,42 +1,53 @@
mod analysis;
mod battery;
mod config;
mod diag;
mod display;
mod dummy_analyzer;
mod error;
mod key_input;
mod notifications;
mod pcap;
mod qmdl_store;
mod server;
mod stats;
use std::net::SocketAddr;
use std::sync::Arc;
use crate::battery::run_battery_notification_worker;
use crate::config::{parse_args, parse_config};
use crate::diag::run_diag_read_thread;
use crate::error::RayhunterError;
use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{get_qmdl, serve_static, ServerState};
use crate::stats::get_system_stats;
use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
set_config, set_time_offset, test_notification,
};
use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{
get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus,
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
};
use axum::Router;
use axum::response::Redirect;
use axum::routing::{get, post};
use axum::Router;
use diag::{
delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording,
DiagDeviceCtrlMessage,
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
start_recording, stop_recording,
};
use log::{error, info};
use qmdl_store::RecordingStoreError;
use rayhunter::Device;
use rayhunter::diag_device::DiagDevice;
use stats::get_qmdl_manifest;
use std::net::SocketAddr;
use std::sync::Arc;
use stats::get_log;
use tokio::net::TcpListener;
use tokio::select;
use tokio::sync::RwLock;
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{oneshot, RwLock};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
type AppRouter = Router<Arc<ServerState>>;
@@ -45,8 +56,10 @@ fn get_router() -> AppRouter {
Router::new()
.route("/api/pcap/{name}", get(get_pcap))
.route("/api/qmdl/{name}", get(get_qmdl))
.route("/api/zip/{name}", get(get_zip))
.route("/api/system-stats", get(get_system_stats))
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/log", get(get_log))
.route("/api/start-recording", post(start_recording))
.route("/api/stop-recording", post(stop_recording))
.route("/api/delete-recording/{name}", post(delete_recording))
@@ -54,6 +67,12 @@ fn get_router() -> AppRouter {
.route("/api/analysis-report/{name}", get(get_analysis_report))
.route("/api/analysis", get(get_analysis_status))
.route("/api/analysis/{name}", post(start_analysis))
.route("/api/config", get(get_config))
.route("/api/config", post(set_config))
.route("/api/test-notification", post(test_notification))
.route("/api/time", get(get_time))
.route("/api/time-offset", post(set_time_offset))
.route("/api/debug/display-state", post(debug_set_display_state))
.route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static))
}
@@ -63,31 +82,26 @@ fn get_router() -> AppRouter {
// (i.e. user hit ctrl+c)
async fn run_server(
task_tracker: &TaskTracker,
config: &config::Config,
state: Arc<ServerState>,
server_shutdown_rx: oneshot::Receiver<()>,
shutdown_token: CancellationToken,
) -> JoinHandle<()> {
info!("spinning up server");
let app = get_router().with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
let listener = TcpListener::bind(&addr).await.unwrap();
let app = get_router().with_state(state);
task_tracker.spawn(async move {
info!("The orca is hunting for stingrays...");
axum::serve(listener, app)
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
.with_graceful_shutdown(shutdown_token.cancelled_owned())
.await
.unwrap();
})
}
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
server_shutdown_rx.await.unwrap();
info!("Server received shutdown signal, exiting...");
}
// Loads a RecordingStore if one exists, and if not, only create one if we're
// not in debug mode. If we fail to parse the manifest AND we're not in debug
// mode, try to recover by making a new (empty) manifest in the same directory.
// mode, try to recover the manifest from the existing QMDL files
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
if config.debug_mode {
@@ -102,9 +116,9 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
match RecordingStore::load(&config.qmdl_store_path).await {
Ok(store) => Ok(store),
Err(RecordingStoreError::ParseManifestError(err)) => {
error!("failed to parse QMDL manifest: {}", err);
info!("creating new empty manifest...");
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
error!("failed to parse QMDL manifest: {err}");
info!("recovering manifest from existing QMDL files...");
Ok(RecordingStore::recover(&config.qmdl_store_path).await?)
}
Err(err) => Err(err.into()),
}
@@ -116,57 +130,70 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
// Start a thread that'll track when user hits ctrl+c. When that happens,
// trigger various cleanup tasks, including sending signals to other threads to
// shutdown
fn run_ctrl_c_thread(
fn run_shutdown_thread(
task_tracker: &TaskTracker,
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
server_shutdown_tx: oneshot::Sender<()>,
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
shutdown_token: CancellationToken,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_tx: Sender<AnalysisCtrlMessage>,
) -> JoinHandle<Result<(), RayhunterError>> {
task_tracker.spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
let mut qmdl_store = qmdl_store_lock.write().await;
if qmdl_store.current_entry.is_some() {
info!("Closing current QMDL entry...");
qmdl_store.close_current_entry().await?;
info!("Done!");
}
info!("create shutdown thread");
server_shutdown_tx
.send(())
.expect("couldn't send server shutdown signal");
info!("sending UI shutdown");
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
ui_shutdown_tx
.send(())
.expect("couldn't send ui shutdown signal");
task_tracker.spawn(async move {
select! {
res = tokio::signal::ctrl_c() => {
if let Err(err) = res {
error!("Unable to listen for shutdown signal: {err}");
}
diag_device_sender
.send(DiagDeviceCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to diag thread");
analysis_tx
.send(AnalysisCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to analysis thread");
}
Err(err) => {
error!("Unable to listen for shutdown signal: {}", err);
}
_ = shutdown_token.cancelled() => {}
}
let mut qmdl_store = qmdl_store_lock.write().await;
if qmdl_store.current_entry.is_some() {
info!("Closing current QMDL entry...");
qmdl_store.close_current_entry().await?;
info!("Done!");
}
shutdown_token.cancel();
diag_device_sender
.send(DiagDeviceCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to diag thread");
analysis_tx
.send(AnalysisCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to analysis thread");
Ok(())
})
}
#[tokio::main]
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), RayhunterError> {
env_logger::init();
rayhunter::init_logging(log::LevelFilter::Info);
#[cfg(feature = "rustcrypto-tls")]
{
rustls_rustcrypto::provider()
.install_default()
.expect("Couldn't install rustcrypto provider");
}
let args = parse_args();
let config = parse_config(&args.config_path)?;
loop {
let config = parse_config(&args.config_path).await?;
if !run_with_config(&args, config).await? {
return Ok(());
}
}
}
async fn run_with_config(
args: &config::Args,
config: config::Config,
) -> Result<bool, RayhunterError> {
// TaskTrackers give us an interface to spawn tokio threads, and then
// eventually await all of them ending
let task_tracker = TaskTracker::new();
@@ -175,14 +202,21 @@ async fn main() -> Result<(), RayhunterError> {
let store = init_qmdl_store(&config).await?;
let analysis_status = AnalysisStatus::new(&store);
let qmdl_store_lock = Arc::new(RwLock::new(store));
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
let mut maybe_ui_shutdown_tx = None;
let restart_token = CancellationToken::new();
let shutdown_token = restart_token.child_token();
// Ensure shutdown_token is cancelled when this function exits for any
// reason (e.g. diag device init failure), so all spawned tasks get
// signaled to stop.
let _shutdown_guard = shutdown_token.clone().drop_guard();
let notification_service = NotificationService::new(config.ntfy_url.clone());
if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
let mut dev = DiagDevice::new()
info!("Using configuration for device: {0:?}", config.device);
let mut dev = DiagDevice::new(&config.device)
.await
.map_err(RayhunterError::DiagInitError)?;
dev.config_logs()
@@ -193,47 +227,82 @@ async fn main() -> Result<(), RayhunterError> {
run_diag_read_thread(
&task_tracker,
dev,
rx,
diag_rx,
diag_tx.clone(),
ui_update_tx.clone(),
qmdl_store_lock.clone(),
config.enable_dummy_analyzer,
analysis_tx.clone(),
config.analyzers.clone(),
notification_service.new_handler(),
);
info!("Starting UI");
display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
let update_ui = match &config.device {
Device::Orbic => display::orbic::update_ui,
Device::Tplink => display::tplink::update_ui,
Device::Tmobile => display::tmobile::update_ui,
Device::Wingtech => display::wingtech::update_ui,
Device::Pinephone => display::headless::update_ui,
Device::Uz801 => display::uz801::update_ui,
};
update_ui(&task_tracker, &config, shutdown_token.clone(), ui_update_rx);
info!("Starting Key Input service");
key_input::run_key_input_thread(
&task_tracker,
&config,
diag_tx.clone(),
shutdown_token.clone(),
);
}
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
info!("create shutdown thread");
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
run_analysis_thread(
&task_tracker,
analysis_rx,
qmdl_store_lock.clone(),
analysis_status_lock.clone(),
config.enable_dummy_analyzer,
config.analyzers.clone(),
);
run_ctrl_c_thread(
run_shutdown_thread(
&task_tracker,
tx.clone(),
server_shutdown_tx,
maybe_ui_shutdown_tx,
diag_tx.clone(),
shutdown_token.clone(),
qmdl_store_lock.clone(),
analysis_tx.clone(),
);
run_battery_notification_worker(
&task_tracker,
config.device.clone(),
notification_service.new_handler(),
shutdown_token.clone(),
);
run_notification_worker(
&task_tracker,
notification_service,
config.enabled_notifications.clone(),
);
let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: tx,
ui_update_sender: ui_update_tx,
debug_mode: config.debug_mode,
diag_device_ctrl_sender: diag_tx,
analysis_status_lock,
analysis_sender: analysis_tx,
daemon_restart_token: restart_token.clone(),
ui_update_sender: Some(ui_update_tx),
});
run_server(&task_tracker, &config, state, server_shutdown_rx).await;
run_server(&task_tracker, state, shutdown_token.clone()).await;
task_tracker.close();
task_tracker.wait().await;
info!("see you space cowboy...");
Ok(())
Ok(restart_token.is_cancelled())
}
#[cfg(test)]

381
daemon/src/notifications.rs Normal file
View File

@@ -0,0 +1,381 @@
use std::{
cmp::min,
collections::HashMap,
time::{Duration, Instant},
};
use log::error;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::mpsc::{self, error::TryRecvError};
use tokio_util::task::TaskTracker;
#[derive(Error, Debug)]
pub enum NotificationError {
#[error("HTTP request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Server returned error status: {0}")]
HttpError(reqwest::StatusCode),
}
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
pub enum NotificationType {
Warning,
LowBattery,
}
pub struct Notification {
notification_type: NotificationType,
message: String,
debounce: Option<Duration>,
}
impl Notification {
pub fn new(
notification_type: NotificationType,
message: String,
debounce: Option<Duration>,
) -> Self {
Notification {
notification_type,
message,
debounce,
}
}
}
struct NotificationStatus {
message: String,
needs_sending: bool,
last_sent: Option<Instant>,
last_attempt: Option<Instant>,
failed_since_last_success: u32,
}
pub struct NotificationService {
url: Option<String>,
tx: mpsc::Sender<Notification>,
rx: mpsc::Receiver<Notification>,
}
impl NotificationService {
pub fn new(url: Option<String>) -> Self {
let (tx, rx) = mpsc::channel(10);
Self { url, tx, rx }
}
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
self.tx.clone()
}
}
/// Sends a notification message to the specified URL.
pub async fn send_notification(
http_client: &reqwest::Client,
url: &str,
message: String,
) -> Result<(), NotificationError> {
let response = http_client.post(url).body(message).send().await?;
if response.status().is_success() {
Ok(())
} else {
Err(NotificationError::HttpError(response.status()))
}
}
pub fn run_notification_worker(
task_tracker: &TaskTracker,
mut notification_service: NotificationService,
enabled_notifications: Vec<NotificationType>,
) {
task_tracker.spawn(async move {
if let Some(url) = notification_service.url
&& !url.is_empty()
{
let mut notification_statuses = HashMap::new();
let http_client = reqwest::Client::new();
loop {
// Get any notifications since the last time we checked
loop {
match notification_service.rx.try_recv() {
Ok(notification) => {
if !enabled_notifications.contains(&notification.notification_type) {
continue;
}
let status = notification_statuses
.entry(notification.notification_type)
.or_insert_with(|| NotificationStatus {
message: "".to_string(),
needs_sending: true,
last_sent: None,
last_attempt: None,
failed_since_last_success: 0,
});
// Ignore if we're in the debounce period
if let Some(debounce) = notification.debounce
&& let Some(last_sent) = status.last_sent
&& last_sent.elapsed() < debounce
{
continue;
}
status.message = notification.message;
status.needs_sending = true;
}
Err(TryRecvError::Empty) => {
break;
}
Err(TryRecvError::Disconnected) => {
return;
}
}
}
// Attempt to send pending notifications
for notification in notification_statuses.values_mut() {
if !notification.needs_sending {
continue;
}
// Backoff retries, up to a maximum of 256 seconds.
if let Some(last_attempt) = notification.last_attempt {
let min_wait_time = Duration::from_secs(
2u64.pow(min(notification.failed_since_last_success, 8)),
);
if last_attempt.elapsed() < min_wait_time {
continue;
}
}
match send_notification(&http_client, &url, notification.message.clone()).await
{
Ok(()) => {
notification.last_sent = Some(Instant::now());
notification.failed_since_last_success = 0;
notification.needs_sending = false;
}
Err(e) => {
error!("Failed to send notification: {e}");
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
// If there's no url to send to we'll just discard the notifications
else {
loop {
if notification_service.rx.recv().await.is_none() {
break;
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, body::Bytes, extract::State, routing::post};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[derive(Clone)]
struct TestServerState {
received_messages: Arc<Mutex<Vec<String>>>,
}
async fn capture_notification(
State(state): State<TestServerState>,
body: Bytes,
) -> &'static str {
let message = String::from_utf8_lossy(&body).to_string();
state.received_messages.lock().await.push(message);
"OK"
}
async fn setup_test_server() -> (Arc<Mutex<Vec<String>>>, String) {
#[cfg(feature = "rustcrypto-tls")]
{
let _ = rustls_rustcrypto::provider().install_default();
}
let received_messages = Arc::new(Mutex::new(Vec::new()));
let test_state = TestServerState {
received_messages: received_messages.clone(),
};
let app = Router::new()
.route("/", post(capture_notification))
.with_state(test_state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
(received_messages, url)
}
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
drop(sender);
tracker.close();
tracker.wait().await;
}
#[tokio::test]
async fn test_notification_worker_sends_message() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning message".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "test warning message");
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_filters_disabled_types() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
notification_sender
.send(Notification::new(
NotificationType::LowBattery,
"test low battery".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "test warning");
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_sends_enabled_types() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning, NotificationType::LowBattery],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
notification_sender
.send(Notification::new(
NotificationType::LowBattery,
"test low battery".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 2);
// these are interchangeable, ordering not guaranteed
assert!(messages.contains(&"test warning".to_string()));
assert!(messages.contains(&"test low battery".to_string()));
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_with_no_url() {
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(None);
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(notification_sender, task_tracker).await;
}
}

View File

@@ -1,19 +1,18 @@
use crate::ServerState;
use anyhow::Error;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use futures::TryStreamExt;
use log::error;
use rayhunter::diag::DataType;
use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::qmdl::QmdlReader;
use std::sync::Arc;
use std::{future, pin::pin};
use tokio::io::duplex;
use tokio::io::{AsyncRead, AsyncWrite, duplex};
use tokio_util::io::ReaderStream;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
@@ -21,12 +20,15 @@ use tokio_util::io::ReaderStream;
// pcap data to a channel that's piped to the client.
pub async fn get_pcap(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
Path(mut qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
if qmdl_name.ends_with("pcapng") {
qmdl_name = qmdl_name.trim_end_matches(".pcapng").to_string();
}
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {}", qmdl_name),
format!("couldn't find manifest entry with name {qmdl_name}"),
))?;
if entry.qmdl_size_bytes == 0 {
return Err((
@@ -38,39 +40,14 @@ pub async fn get_pcap(
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
// the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes)
let (reader, writer) = duplex(1024);
let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap();
pcap_writer.write_iface_header().await.unwrap();
tokio::spawn(async move {
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
let mut messages_stream = pin!(reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
while let Some(container) = messages_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
for maybe_msg in container.into_messages() {
match maybe_msg {
Ok(msg) => {
let maybe_gsmtap_msg =
gsmtap_parser::parse(msg).expect("error parsing gsmtap message");
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp)
.await
.expect("error writing pcap packet");
}
}
Err(e) => error!("error parsing message: {:?}", e),
}
}
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
error!("failed to generate PCAP: {e:?}");
}
});
@@ -78,3 +55,39 @@ pub async fn get_pcap(
let body = Body::from_stream(ReaderStream::new(reader));
Ok((headers, body).into_response())
}
pub async fn generate_pcap_data<R, W>(
writer: W,
qmdl_file: R,
qmdl_size_bytes: usize,
) -> Result<(), Error>
where
W: AsyncWrite + Unpin + Send,
R: AsyncRead + Unpin,
{
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
pcap_writer.write_iface_header().await?;
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
while let Some(container) = reader.get_next_messages_container().await? {
if container.data_type != DataType::UserSpace {
continue;
}
for maybe_msg in container.into_messages() {
match maybe_msg {
Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp)
.await?;
}
}
Err(e) => error!("error parsing message: {e:?}"),
}
}
}
Ok(())
}

View File

@@ -1,12 +1,14 @@
use std::io::{self, ErrorKind};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use log::{info, warn};
use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{
fs::{self, try_exists, File, OpenOptions},
fs::{self, File, OpenOptions, try_exists},
io::AsyncWriteExt,
};
@@ -49,7 +51,6 @@ pub struct ManifestEntry {
pub start_time: DateTime<Local>,
pub last_message_time: Option<DateTime<Local>>,
pub qmdl_size_bytes: usize,
pub analysis_size_bytes: usize,
pub rayhunter_version: Option<String>,
pub system_os: Option<String>,
pub arch: Option<String>,
@@ -57,14 +58,13 @@ pub struct ManifestEntry {
impl ManifestEntry {
fn new() -> Self {
let now = Local::now();
let now = rayhunter::clock::get_adjusted_now();
let metadata = RuntimeMetadata::new();
ManifestEntry {
name: format!("{}", now.timestamp()),
start_time: now,
last_message_time: None,
qmdl_size_bytes: 0,
analysis_size_bytes: 0,
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
@@ -138,6 +138,83 @@ impl RecordingStore {
Ok(store)
}
// Does a best-effort attempt to recover the manifest from a directory of
// QMDL files. We expect these files to be named like "<timestamp>.qmdl",
// and skip any files which don't match that pattern.
pub async fn recover<P>(path: P) -> Result<Self, RecordingStoreError>
where
P: AsRef<Path>,
{
let mut dir_entries = fs::read_dir(path.as_ref())
.await
.map_err(RecordingStoreError::OpenDirError)?;
let mut manifest_entries = Vec::new();
while let Some(entry) = dir_entries
.next_entry()
.await
.map_err(RecordingStoreError::OpenDirError)?
{
let os_filename = entry.file_name();
let Some(filename) = os_filename.to_str() else {
continue;
};
if !filename.ends_with(".qmdl") {
continue;
}
let stem = filename.trim_end_matches(".qmdl");
let Ok(start_timestamp) = stem.parse::<i64>() else {
warn!("QMDL file has invalid name {os_filename:?}, skipping");
continue;
};
let metadata = match entry.metadata().await {
Ok(metadata) => metadata,
Err(err) => {
warn!("failed to read QMDL file metadata: {err:?}, skipping");
continue;
}
};
let Some(start_time) = DateTime::from_timestamp(start_timestamp, 0) else {
warn!("QMDL filename {os_filename:?} gave an invalid timestamp, skipping");
continue;
};
let Ok(last_message_time) = metadata.modified() else {
warn!("failed to get modified time for QMDL file {os_filename:?}, skipping");
continue;
};
info!("successfully recovered QMDL entry {os_filename:?}!");
manifest_entries.push(ManifestEntry {
name: stem.to_string(),
start_time: start_time.into(),
last_message_time: Some(last_message_time.into()),
qmdl_size_bytes: metadata.size() as usize,
rayhunter_version: None,
system_os: None,
arch: None,
});
}
// sort chronologically
manifest_entries.sort_by(|a, b| a.start_time.cmp(&b.start_time));
let mut store = RecordingStore {
path: path.as_ref().to_path_buf(),
manifest: Manifest {
entries: manifest_entries,
},
current_entry: None,
};
store.write_manifest().await?;
Ok(store)
}
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
where
P: AsRef<Path>,
@@ -202,7 +279,6 @@ impl RecordingStore {
.open(entry.get_analysis_filepath(&self.path))
.await
.map_err(RecordingStoreError::ReadFileError)?;
self.update_entry_analysis_size(entry_index, 0).await?;
Ok(file)
}
@@ -224,21 +300,14 @@ impl RecordingStore {
size_bytes: usize,
) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
self.write_manifest().await
}
// Sets the given entry's analysis file size
pub async fn update_entry_analysis_size(
&mut self,
entry_index: usize,
size_bytes: usize,
) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].analysis_size_bytes = size_bytes;
self.manifest.entries[entry_index].last_message_time =
Some(rayhunter::clock::get_adjusted_now());
self.write_manifest().await
}
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
// we don't technically need a mutable reference to `self` here, but it
// does prevent multiple concurrent writes across different threads
let tmp_path = self.path.join("manifest.toml.new");
let mut manifest_tmp_file = File::create(&tmp_path)
.await
@@ -273,20 +342,32 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index]))
}
pub async fn delete_entry(&mut self, name: &str) -> Result<ManifestEntry, RecordingStoreError> {
pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) {
Some(entry) => entry.name == name,
None => false,
},
None => false,
}
}
pub async fn delete_entry(&mut self, name: &str) -> Result<(), RecordingStoreError> {
let entry_to_delete_idx = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?;
if let Some(current_entry) = self.current_entry {
if current_entry == entry_to_delete_idx {
match self.current_entry {
Some(current_entry) if current_entry == entry_to_delete_idx => {
self.close_current_entry().await?;
} else {
}
Some(current_entry) => {
self.current_entry = Some(current_entry - 1);
}
}
None => {}
};
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?;
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
@@ -297,7 +378,7 @@ impl RecordingStore {
remove_file_if_exists(&analysis_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
Ok(entry_to_delete)
Ok(())
}
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
@@ -369,9 +450,11 @@ mod tests {
RecordingStore::read_manifest(dir.path()).await.unwrap(),
store.manifest
);
assert!(store.manifest.entries[entry_index]
.last_message_time
.is_none());
assert!(
store.manifest.entries[entry_index]
.last_message_time
.is_none()
);
store
.update_entry_qmdl_size(entry_index, 1000)

426
daemon/src/server.rs Normal file
View File

@@ -0,0 +1,426 @@
use anyhow::Error;
use async_zip::Compression;
use async_zip::ZipEntryBuilder;
use async_zip::tokio::write::ZipFileWriter;
use axum::Json;
use axum::body::Body;
use axum::extract::Path;
use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Local};
use log::{error, warn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::fs::write;
use tokio::io::{AsyncReadExt, copy, duplex};
use tokio::sync::RwLock;
use tokio::sync::mpsc::Sender;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream;
use tokio_util::sync::CancellationToken;
use crate::DiagDeviceCtrlMessage;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config;
use crate::display::DisplayState;
use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore;
pub struct ServerState {
pub config_path: String,
pub config: Config,
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_token: CancellationToken,
pub ui_update_sender: Option<Sender<DisplayState>>,
}
pub async fn get_qmdl(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {qmdl_idx}"),
))?;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {err}"),
)
})?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
let headers = [
(CONTENT_TYPE, "application/octet-stream"),
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
];
let body = Body::from_stream(qmdl_stream);
Ok((headers, body).into_response())
}
pub async fn serve_static(
State(_): State<Arc<ServerState>>,
Path(path): Path<String>,
) -> impl IntoResponse {
let path = path.trim_start_matches('/');
match path {
"rayhunter_orca_only.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/rayhunter_orca_only.png"),
)
.into_response(),
"rayhunter_text.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/rayhunter_text.png"),
)
.into_response(),
"favicon.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/favicon.png"),
)
.into_response(),
"index.html" => (
[
(header::CONTENT_TYPE, HeaderValue::from_static("text/html")),
(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")),
],
include_bytes!("../web/build/index.html.gz"),
)
.into_response(),
path => {
warn!("404 on path: {path}");
StatusCode::NOT_FOUND.into_response()
}
}
}
pub async fn get_config(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Config>, (StatusCode, String)> {
Ok(Json(state.config.clone()))
}
pub async fn set_config(
State(state): State<Arc<ServerState>>,
Json(config): Json<Config>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let config_str = toml::to_string_pretty(&config).map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to serialize config as TOML: {err}"),
)
})?;
write(&state.config_path, config_str).await.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to write config file: {err}"),
)
})?;
// Trigger daemon restart after writing config
state.daemon_restart_token.cancel();
Ok((
StatusCode::ACCEPTED,
"wrote config and triggered restart".to_string(),
))
}
pub async fn test_notification(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let url = state.config.ntfy_url.as_ref().ok_or((
StatusCode::BAD_REQUEST,
"No notification URL configured".to_string(),
))?;
if url.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Notification URL is empty".to_string(),
));
}
let http_client = reqwest::Client::new();
let message = "Test notification from Rayhunter".to_string();
crate::notifications::send_notification(&http_client, url, message)
.await
.map(|()| {
(
StatusCode::OK,
"Test notification sent successfully".to_string(),
)
})
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to send test notification: {e}"),
)
})
}
/// Response for GET /api/time
#[derive(Serialize)]
pub struct TimeResponse {
/// The raw system time (without clock offset)
pub system_time: DateTime<Local>,
/// The adjusted time (system time + offset)
pub adjusted_time: DateTime<Local>,
/// The current offset in seconds
pub offset_seconds: i64,
}
/// Request for POST /api/time-offset
#[derive(Deserialize)]
pub struct SetTimeOffsetRequest {
/// The offset to set, in seconds
pub offset_seconds: i64,
}
pub async fn get_time() -> Json<TimeResponse> {
let system_time = Local::now();
let adjusted_time = rayhunter::clock::get_adjusted_now();
let offset_seconds = adjusted_time
.signed_duration_since(system_time)
.num_seconds();
Json(TimeResponse {
system_time,
adjusted_time,
offset_seconds,
})
}
pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode {
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
StatusCode::OK
}
pub async fn get_zip(
State(state): State<Arc<ServerState>>,
Path(entry_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned();
let (entry_index, qmdl_size_bytes) = {
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find entry with name {qmdl_idx}"),
))?;
if entry.qmdl_size_bytes == 0 {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string(),
));
}
(entry_index, entry.qmdl_size_bytes)
};
let qmdl_store_lock = state.qmdl_store_lock.clone();
let (reader, writer) = duplex(8192);
tokio::spawn(async move {
let result: Result<(), Error> = async {
let mut zip = ZipFileWriter::with_tokio(writer);
// Add QMDL file
{
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let mut qmdl_file = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
copy(&mut qmdl_file, &mut entry_writer).await?;
entry_writer.into_inner().close().await?;
}
// Add PCAP file
{
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.pcapng").into(), Compression::Stored);
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let qmdl_file_for_pcap = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
if let Err(e) =
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
{
// if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL.
error!("Failed to generate PCAP: {e:?}");
}
entry_writer.into_inner().close().await?;
}
zip.close().await?;
Ok(())
}
.await;
if let Err(e) = result {
error!("Error generating ZIP file: {e:?}");
}
});
let headers = [(CONTENT_TYPE, "application/zip")];
let body = Body::from_stream(ReaderStream::new(reader));
Ok((headers, body).into_response())
}
pub async fn debug_set_display_state(
State(state): State<Arc<ServerState>>,
Json(display_state): Json<DisplayState>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if let Some(ui_sender) = &state.ui_update_sender {
ui_sender.send(display_state).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to send display state update".to_string(),
)
})?;
Ok((
StatusCode::OK,
"display state updated successfully".to_string(),
))
} else {
Err((
StatusCode::SERVICE_UNAVAILABLE,
"display system not available".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_zip::base::read::mem::ZipFileReader;
use axum::extract::{Path, State};
use tempfile::TempDir;
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().to_path_buf();
let store = crate::qmdl_store::RecordingStore::create(&store_path)
.await
.unwrap();
(temp_dir, Arc::new(RwLock::new(store)))
}
async fn create_test_entry_with_data(
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
test_data: &[u8],
) -> String {
let entry_name = {
let mut store = store_lock.write().await;
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
if !test_data.is_empty() {
use tokio::io::AsyncWriteExt;
qmdl_file.write_all(test_data).await.unwrap();
qmdl_file.flush().await.unwrap();
}
let current_entry = store.current_entry.unwrap();
let entry = &store.manifest.entries[current_entry];
let entry_name = entry.name.clone();
store
.update_entry_qmdl_size(current_entry, test_data.len())
.await
.unwrap();
entry_name
};
let mut store = store_lock.write().await;
store.close_current_entry().await.unwrap();
entry_name
}
fn create_test_server_state(
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
) -> Arc<ServerState> {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
let analysis_status = {
let store = store_lock.try_read().unwrap();
crate::analysis::AnalysisStatus::new(&store)
};
Arc::new(ServerState {
config_path: "/tmp/test_config.toml".to_string(),
config: Config::default(),
qmdl_store_lock: store_lock,
diag_device_ctrl_sender: tx,
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
analysis_sender: analysis_tx,
daemon_restart_token: CancellationToken::new(),
ui_update_sender: None,
})
}
#[tokio::test]
async fn test_get_zip_success() {
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E];
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
let state = create_test_server_state(store_lock);
let result = get_zip(State(state), Path(entry_name.clone())).await;
assert!(result.is_ok());
let response = result.unwrap();
let headers = response.headers();
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
let body = response.into_body();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
let filenames = zip_reader
.file()
.entries()
.iter()
.map(|entry| entry.filename().as_str().unwrap().to_owned())
.collect::<Vec<String>>();
assert_eq!(
filenames,
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
);
}
}

View File

@@ -1,13 +1,15 @@
use std::sync::Arc;
use crate::qmdl_store::ManifestEntry;
use crate::battery::get_battery_status;
use crate::error::RayhunterError;
use crate::server::ServerState;
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use log::error;
use rayhunter::util::RuntimeMetadata;
use rayhunter::{Device, util::RuntimeMetadata};
use serde::Serialize;
use tokio::process::Command;
@@ -16,14 +18,24 @@ pub struct SystemStats {
pub disk_stats: DiskStats,
pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub battery_status: Option<BatteryState>,
}
impl SystemStats {
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
Ok(Self {
disk_stats: DiskStats::new(qmdl_path).await?,
memory_stats: MemoryStats::new().await?,
disk_stats: DiskStats::new(qmdl_path, device).await?,
memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await {
Ok(status) => Some(status),
Err(RayhunterError::FunctionNotSupportedForDeviceError) => None,
Err(err) => {
log::error!("Failed to get battery status: {err}");
None
}
},
})
}
}
@@ -40,13 +52,22 @@ pub struct DiskStats {
impl DiskStats {
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
// the QMDL file
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
let mut df_cmd = Command::new("df");
// the QMDL file.
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
// Uz801 needs to be told to use the busybox df specifically
let mut df_cmd: Command;
if matches!(device, Device::Uz801) {
df_cmd = Command::new("busybox");
df_cmd.arg("df");
} else {
df_cmd = Command::new("df");
}
df_cmd.arg("-h");
df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?;
let mut parts = stdout.split_whitespace().skip(7).to_owned();
// Handle standard df -h format
let mut parts = stdout.split_whitespace().skip(7);
Ok(Self {
partition: parts.next().ok_or("error parsing df output")?.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
@@ -83,9 +104,16 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
}
impl MemoryStats {
// runs "free -k" and parses the output to retrieve memory stats
pub async fn new() -> Result<Self, String> {
let mut free_cmd = Command::new("free");
// runs "free -k" and parses the output to retrieve memory stats for most devices,
pub async fn new(device: &Device) -> Result<Self, String> {
// Use busybox for Uz801
let mut free_cmd: Command;
if matches!(device, Device::Uz801) {
free_cmd = Command::new("busybox");
free_cmd.arg("free");
} else {
free_cmd = Command::new("free");
}
free_cmd.arg("-k");
let stdout = get_cmd_output(free_cmd).await?;
let mut numbers = stdout
@@ -102,7 +130,7 @@ impl MemoryStats {
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
fn humanize_kb(kb: usize) -> String {
if kb < 1000 {
return format!("{}K", kb);
return format!("{kb}K");
}
format!("{:.1}M", kb as f64 / 1024.0)
}
@@ -111,10 +139,10 @@ pub async fn get_system_stats(
State(state): State<Arc<ServerState>>,
) -> Result<Json<SystemStats>, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
match SystemStats::new(qmdl_store.path.to_str().unwrap(), &state.config.device).await {
Ok(stats) => Ok(Json(stats)),
Err(err) => {
error!("error getting system stats: {}", err);
error!("error getting system stats: {err}");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"error getting system stats".to_string(),
@@ -140,3 +168,9 @@ pub async fn get_qmdl_manifest(
current_entry,
}))
}
pub async fn get_log() -> Result<String, (StatusCode, String)> {
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}

View File

@@ -19,6 +19,3 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
package-lock.json
yarn.lock

View File

@@ -2,3 +2,6 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
# Static Assets
static/pico.min.css

15
daemon/web/.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -0,0 +1,53 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
{
ignores: ['build/', '.svelte-kit/**', 'dist/'],
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'function',
format: ['snake_case'],
},
{
selector: 'method',
format: ['snake_case'],
},
],
},
}
);

5107
daemon/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
daemon/web/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "web",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && gzip -9 ./build/index.html",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"fix": "eslint --fix ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.49.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/eslint": "^9.6.0",
"@types/node": "^24.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^7.1.11",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

3
daemon/web/src/app.css Normal file
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

13
daemon/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
daemon/web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="width: 100%">
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,24 @@
export class ActionError extends Error {
// The number of this an identical error has happened.
// This is shown as a number next to the error in the UI.
times = $state(1);
constructor(message: string, cause: Error) {
super(message);
this.cause = cause;
}
}
export const action_errors: ActionError[] = $state([]);
export function add_error(e: Error, msg: string): void {
for (const existing of action_errors) {
if (existing.message === msg) {
existing.times += 1;
return;
}
}
const action_error = new ActionError(msg, e);
action_errors.unshift(action_error);
console.log(action_errors.length);
}

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
import { type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 2,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 2,
},
],
report_version: 2,
},
{
skipped_message_reason: 'The reason why the message was skipped',
},
{
packet_timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
{
event_type: 'Low',
message: 'Something nasty happened',
},
],
},
];
describe('analysis report parsing', () => {
it('parses v2 example analysis', () => {
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(2);
expect(report.metadata.analyzers).toEqual([
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 2,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 2,
},
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
if (report.rows[1].type === AnalysisRowType.Analysis) {
const row = report.rows[1];
expect(row.events).toHaveLength(2);
expect(row.events[0]).toBeNull();
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
expect(event!.event_type).toEqual('Low');
} else {
throw 'wrong row type';
}
});
});

View File

@@ -0,0 +1,138 @@
import { parse_ndjson, type NewlineDeliminatedJson } from './ndjson';
import { req } from './utils.svelte';
export type AnalysisReport = {
metadata: ReportMetadata;
rows: AnalysisRow[];
statistics: ReportStatistics;
};
export type ReportStatistics = {
num_warnings: number;
num_informational_logs: number;
num_skipped_packets: number;
};
export class ReportMetadata {
public analyzers: AnalyzerMetadata[];
public rayhunter: RayhunterMetadata;
public report_version: number;
constructor(ndjson: any) {
this.analyzers = ndjson.analyzers;
this.rayhunter = ndjson.rayhunter;
this.report_version = ndjson.report_version || 2; // Default to v2
}
}
export type RayhunterMetadata = {
rayhunter_version: string;
system_os: string;
arch: string;
};
export type AnalyzerMetadata = {
name: string;
description: string;
version: number;
};
export type AnalysisRow = SkippedPacket | PacketAnalysis;
export enum AnalysisRowType {
Skipped,
Analysis,
}
export type SkippedPacket = {
type: AnalysisRowType.Skipped;
reason: string;
};
export type PacketAnalysis = {
type: AnalysisRowType.Analysis;
packet_timestamp: Date;
events: Event[];
};
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High';
export type Event = {
event_type: EventType;
message: string;
} | null;
function get_event(event_json: any): Event {
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
throw `Invalid/unhandled event type: ${event_json.event_type}`;
}
return event_json;
}
function get_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
if (row_json.skipped_message_reason) {
rows.push({
type: AnalysisRowType.Skipped,
reason: row_json.skipped_message_reason,
});
} else {
const events: Event[] = row_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else {
return get_event(event_json);
}
});
rows.push({
type: AnalysisRowType.Analysis,
packet_timestamp: new Date(row_json.packet_timestamp),
events,
});
}
}
return rows;
}
function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
let num_warnings = 0;
let num_informational_logs = 0;
let num_skipped_packets = 0;
for (const row of rows) {
if (row.type === AnalysisRowType.Skipped) {
num_skipped_packets++;
} else {
for (const event of row.events) {
if (event !== null) {
if (event.event_type === 'Informational') {
num_informational_logs++;
} else {
num_warnings++;
}
}
}
}
}
return {
num_warnings,
num_informational_logs,
num_skipped_packets,
};
}
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata = new ReportMetadata(report_json[0]);
const rows = get_rows(report_json.slice(1));
const statistics = get_report_stats(rows);
return {
statistics,
metadata,
rows,
};
}
export async function get_report(name: string): Promise<AnalysisReport> {
const report_json = parse_ndjson(await req('GET', `/api/analysis-report/${name}`));
return parse_finished_report(report_json);
}

View File

@@ -1,6 +1,5 @@
import { get_report, type AnalysisReport } from "./analysis.svelte";
import type { Manifest, ManifestEntry } from "./manifest.svelte";
import { req } from "./utils.svelte";
import { get_report, type AnalysisReport } from './analysis.svelte';
import { req } from './utils.svelte';
export enum AnalysisStatus {
// rayhunter is currently analyzing this entry (note that this is distinct
@@ -19,16 +18,14 @@ type AnalysisStatusJson = {
};
export type AnalysisResult = {
name: string,
status: AnalysisStatus,
name: string;
status: AnalysisStatus;
};
export class AnalysisManager {
public status: Map<string, AnalysisStatus> = new Map();
public reports: Map<string, AnalysisReport | string> = new Map();
public async run_analysis(name: string) {
await req('POST', `/api/analysis/${name}`);
public status: Map<string, AnalysisStatus> = $state(new Map());
public reports: Map<string, AnalysisReport | string> = $state(new Map());
public set_queued_status(name: string) {
this.status.set(name, AnalysisStatus.Queued);
this.reports.delete(name);
}
@@ -53,11 +50,13 @@ export class AnalysisManager {
// fetch the analysis report
this.reports.delete(entry);
get_report(entry).then(report => {
this.reports.set(entry, report);
}).catch(err => {
this.reports.set(entry, `Failed to get analysis: ${err}`);
});
get_report(entry)
.then((report) => {
this.reports.set(entry, report);
})
.catch((err) => {
this.reports.set(entry, `Failed to get analysis: ${err}`);
});
}
}
}

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { action_errors } from '../action_errors.svelte';
let pos = $state(0);
let current_error = $derived(action_errors[pos]);
function prev_error() {
if (pos > 0) pos -= 1;
else pos = action_errors.length - 1;
}
function next_error() {
if (pos + 1 < action_errors.length) pos += 1;
else pos = 0;
}
function clear_errors() {
pos = 0;
action_errors.length = 0;
}
</script>
{#if action_errors.length > 0}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
>
<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">
<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}` : ''}
</span>
<div class="flex items-center mb-2">
{#if action_errors.length > 1}
<span>{pos + 1}/{action_errors.length}</span>
<button title="previous error" aria-label="previous error" onclick={prev_error}>
<svg
aria-hidden="true"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
/>
</svg>
</button>
<button title="next error" aria-label="next error" onclick={next_error}>
<svg
aria-hidden="true"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
/>
</svg>
</button>
{/if}
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
<path
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
/>
</svg>
</button>
</div>
</div>
<span>{current_error.message}</span>
{#if current_error.cause}
<details>
<summary>Details</summary>
<code>{current_error.cause}</code>
</details>
{/if}
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show More