Compare commits

...

231 Commits

Author SHA1 Message Date
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
oopsbagel aac0c34eaa doc: Wingtech CT2MHS01 2025-06-24 01:10:50 -07:00
178 changed files with 28200 additions and 9348 deletions
+13 -4
View File
@@ -15,6 +15,10 @@ rustflags = ["-C", "target-feature=+crt-static"]
linker = "rust-lld" linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"] 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 # Disable rust-lld for x86 macOS because the linker crashers when compiling
# the installer in release mode with debug info on. # the installer in release mode with debug info on.
# [target.x86_64-apple-darwin] # [target.x86_64-apple-darwin]
@@ -25,17 +29,22 @@ rustflags = ["-C", "target-feature=+crt-static"]
linker = "rust-lld" linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"]
# keep line numbers in stack traces for non-firmware binaries
[profile.release] [profile.release]
# keep line numbers in stack traces for non-firmware binaries
debug = "limited" 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 # optimizations to reduce the binary size of firmware binaries
[profile.firmware] [profile.firmware]
inherits = "release" inherits = "release"
strip = true strip = true
opt-level = "z"
lto = true
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "abort"
debug = false debug = false
+1
View File
@@ -1 +1,2 @@
9fe75ac961c57e508bf7488ce51d596750fa8d37 9fe75ac961c57e508bf7488ce51d596750fa8d37
76ffdf6bada515c9a5f63a600e6f1502288c147a
+9
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
+47
View File
@@ -0,0 +1,47 @@
name: Installer Issue
description: File an bug related to an installer issue.
labels: ["bug", "installer"]
body:
- 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
+79 -53
View File
@@ -10,9 +10,8 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon RUSTFLAGS: "-Dwarnings"
FILE_RAYHUNTER_DAEMON_WINGTECH: ../../rayhunter-daemon-wingtech/rayhunter-daemon
jobs: jobs:
files_changed: files_changed:
@@ -23,6 +22,7 @@ jobs:
outputs: outputs:
code_changed: ${{ steps.files_changed.outputs.code_count }} code_changed: ${{ steps.files_changed.outputs.code_count }}
daemon_changed: ${{ steps.files_changed.outputs.daemon_count }} daemon_changed: ${{ steps.files_changed.outputs.daemon_count }}
web_changed: ${{ steps.files_changed.outputs.web_count }}
docs_changed: ${{ steps.files_changed.outputs.docs_count }} docs_changed: ${{ steps.files_changed.outputs.docs_count }}
installer_changed: ${{ steps.files_changed.outputs.installer_count }} installer_changed: ${{ steps.files_changed.outputs.installer_count }}
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }} rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
@@ -36,17 +36,19 @@ jobs:
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }} lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, or if these workflow files are being changed, run everything # If we are on main, or if these workflow files are being changed, run everything
if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo
then then
echo "building everything" echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT" echo code_count=forced >> "$GITHUB_OUTPUT"
echo daemon_count=forced >> "$GITHUB_OUTPUT" echo daemon_count=forced >> "$GITHUB_OUTPUT"
echo web_count=forced >> "$GITHUB_OUTPUT"
echo docs_count=forced >> "$GITHUB_OUTPUT" echo docs_count=forced >> "$GITHUB_OUTPUT"
echo installer_count=forced >> "$GITHUB_OUTPUT" echo installer_count=forced >> "$GITHUB_OUTPUT"
echo rootshell_count=forced >> "$GITHUB_OUTPUT" echo rootshell_count=forced >> "$GITHUB_OUTPUT"
else else
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^bin -e ^installer -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT" 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 ^bin -e ^lib -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 "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
echo "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT" echo "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT"
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT" echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
@@ -97,33 +99,44 @@ jobs:
check_and_test: check_and_test:
needs: files_changed needs: files_changed
if: needs.files_changed.outputs.code_changed != '0' if: needs.files_changed.outputs.code_changed != '0'
strategy:
matrix:
device:
- name: orbic
- name: tplink
- name: wingtech
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Check formatting - name: Check formatting
run: cargo fmt --all --check run: cargo fmt --all --check
- name: Check - name: Check
run: | run: |
pushd bin/web pushd daemon/web
npm install npm install
npm run build npm run build
popd popd
NO_FIRMWARE_BIN=true cargo check --verbose --no-default-features --features=${{ matrix.device.name }} NO_FIRMWARE_BIN=true cargo check --verbose
- name: Run tests - name: Run tests
run: | run: |
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features --features=${{ matrix.device.name }} NO_FIRMWARE_BIN=true cargo test --verbose
- name: Run clippy - name: Run clippy
run: | run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }} NO_FIRMWARE_BIN=true cargo clippy --verbose
test_web_frontend:
needs: files_changed
if: needs.files_changed.outputs.web_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: daemon/web
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm run lint
- run: npm run check
- run: npm run test
windows_installer_check_and_test: windows_installer_check_and_test:
needs: files_changed needs: files_changed
@@ -156,17 +169,20 @@ jobs:
strategy: strategy:
matrix: matrix:
platform: platform:
- name: ubuntu-24 - name: linux-x64
os: ubuntu-latest os: ubuntu-latest
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
- name: ubuntu-24-aarch64 - name: linux-armv7
os: ubuntu-latest
target: armv7-unknown-linux-musleabi
- name: linux-aarch64
os: ubuntu-24.04-arm os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
- name: macos-arm - name: macos-arm
os: macos-latest os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
- name: macos-intel - name: macos-intel
os: macos-13 os: macos-latest
target: x86_64-apple-darwin target: x86_64-apple-darwin
- name: windows-x86_64 - name: windows-x86_64
os: windows-latest os: windows-latest
@@ -174,13 +190,16 @@ jobs:
runs-on: ${{ matrix.platform.os }} runs-on: ${{ matrix.platform.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Build rayhunter-check - name: Build rayhunter-check
run: cargo build --bin rayhunter-check --release run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rayhunter-check-${{ matrix.platform.name }} name: rayhunter-check-${{ matrix.platform.name }}
path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }} path: target/${{ matrix.platform.target }}/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error if-no-files-found: error
build_rootshell: build_rootshell:
@@ -197,7 +216,7 @@ jobs:
with: with:
targets: armv7-unknown-linux-musleabihf targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Build rootshell (arm32) - name: Build rootshell (armv7)
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
@@ -213,12 +232,6 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
strategy:
matrix:
device:
- name: orbic
- name: tplink
- name: wingtech
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -226,9 +239,9 @@ jobs:
with: with:
targets: armv7-unknown-linux-musleabihf targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Build rayhunter-daemon (arm32) - name: Build rayhunter-daemon (armv7)
run: | run: |
pushd bin/web pushd daemon/web
npm install npm install
npm run build npm run build
popd popd
@@ -240,10 +253,10 @@ jobs:
# what the feature selection in rayhunter-daemon is. # what the feature selection in rayhunter-daemon is.
# #
# https://github.com/rust-lang/cargo/issues/4463 # 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 }} cargo build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rayhunter-daemon-${{ matrix.device.name }} name: rayhunter-daemon
path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
if-no-files-found: error if-no-files-found: error
@@ -260,17 +273,20 @@ jobs:
strategy: strategy:
matrix: matrix:
platform: platform:
- name: ubuntu-24 - name: linux-x64
os: ubuntu-latest os: ubuntu-latest
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
- name: ubuntu-24-aarch64 - name: linux-armv7
os: ubuntu-latest
target: armv7-unknown-linux-musleabi
- name: linux-aarch64
os: ubuntu-24.04-arm os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
- name: macos-arm - name: macos-arm
os: macos-latest os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
- name: macos-intel - name: macos-intel
os: macos-13 os: macos-latest
target: x86_64-apple-darwin target: x86_64-apple-darwin
- name: windows-x86_64 - name: windows-x86_64
os: windows-latest os: windows-latest
@@ -283,7 +299,7 @@ jobs:
with: with:
targets: ${{ matrix.platform.target }} targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }} - run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: installer-${{ matrix.platform.name }} name: installer-${{ matrix.platform.name }}
@@ -300,33 +316,43 @@ jobs:
- build_rayhunter - build_rayhunter
- build_rust_installer - build_rust_installer
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
platform:
- linux-x64
- linux-aarch64
- linux-armv7
- macos-intel
- macos-arm
- windows-x86_64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries - name: Fix executable permissions on binaries
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon
- name: Get Rayhunter version - name: Get Rayhunter version
id: get_version id: get_version
run: echo "VERSION=$(grep '^version' bin/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV run: echo "VERSION=$(grep '^version' daemon/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
- name: Setup versioned release directory - name: Setup versioned release directory
run: | run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}" platform="${{ matrix.platform }}"
mkdir "$VERSIONED_DIR" dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
mv rayhunter-daemon-* rootshell/rootshell installer-* dist/* installer/install.ps1 "$VERSIONED_DIR"/ mkdir "$dest"
- name: Archive release directory as zip # Handle installer with proper extension for Windows
run: | if [ "$platform" = "windows-x86_64" ]; then
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}" mv installer-$platform/installer.exe "$dest"/installer.exe
zip -r "$VERSIONED_DIR.zip" "$VERSIONED_DIR" else
- name: Compute SHA256 of zip mv installer-$platform/installer "$dest"/installer
run: | fi
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}" cp -r rayhunter-check-* rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
sha256sum "$VERSIONED_DIR.zip" > "$VERSIONED_DIR.zip.sha256" zip -r "$dest.zip" "$dest"
# TODO: have this create a release directly sha256sum "$dest.zip" > "$dest.zip.sha256"
- name: Upload zip release and sha256 - name: Upload zip release and sha256
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: rayhunter-v${{ env.VERSION }} name: rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}
path: | path: |
rayhunter-v${{ env.VERSION }}.zip rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
rayhunter-v${{ env.VERSION }}.zip.sha256 rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
if-no-files-found: error if-no-files-found: error
+3 -3
View File
@@ -16,8 +16,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Ensure all Cargo.toml files have the same version defined. - name: Ensure all Cargo.toml files have the same version defined.
run: | run: |
defined_versions=$(find lib bin installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l) defined_versions=$(find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
find lib bin installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
echo number of defined versions = $defined_versions echo number of defined versions = $defined_versions
if [ $defined_versions != "1" ] if [ $defined_versions != "1" ]
then then
@@ -45,4 +45,4 @@ jobs:
- name: Create release - name: Create release
run: | run: |
version=$(grep ^version lib/Cargo.toml | cut -d' ' -f3 | tr -d '"') 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-* gh release create --generate-notes -t "Rayhunter v$version" "v$version" rayhunter-v${version}-*/rayhunter-v${version}*.zi*
Generated
+638 -166
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -2,7 +2,8 @@
members = [ members = [
"lib", "lib",
"bin", "daemon",
"check",
"rootshell", "rootshell",
"telcom-parser", "telcom-parser",
"installer", "installer",
+16 -4
View File
@@ -1,7 +1,19 @@
![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
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg) ![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/). ![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 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 can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.md).
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.
&rarr; Check out the [installation guide](https://efforg.github.io/rayhunter/installation.md) to get started.
&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.md)!
&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!*
-178
View File
@@ -1,178 +0,0 @@
use clap::Parser;
use futures::TryStreamExt;
use log::{info, warn};
use rayhunter::{
analysis::analyzer::{AnalyzerConfig, 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_config(&AnalyzerConfig::default());
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)
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
.with_module_level("asn1_codecs", log::LevelFilter::Error)
.init()
.unwrap();
info!("Analyzers:");
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
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;
}
}
}
-305
View File
@@ -1,305 +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, warn};
use rayhunter::analysis::analyzer::AnalyzerConfig;
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,
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>>,
analysis_sender: Sender<AnalysisCtrlMessage>,
enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig,
) {
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, &analyzer_config).await
.expect("failed to create analysis writer"));
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let (qmdl_file, new_analysis_file) = match qmdl_store.new_entry().await {
Ok(x) => x,
Err(e) => {
error!("couldn't create new qmdl entry: {}", e);
continue;
}
};
maybe_qmdl_writer = Some(QmdlWriter::new(qmdl_file));
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, &analyzer_config).await
.expect("failed to write to analysis file"));
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
warn!("couldn't send ui update message: {}", e);
}
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
if let Some((_, entry)) = qmdl_store.get_current_entry() {
if let Err(e) = 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);
}
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;
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
warn!("couldn't send ui update message: {}", e);
}
},
// 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.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()))
}
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 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.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),
)
})?;
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())
}
-202
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));
}
});
}
-27
View File
@@ -1,27 +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;
#[cfg(feature = "wingtech")]
mod wingtech;
#[cfg(feature = "wingtech")]
pub use wingtech::update_ui;
pub enum DisplayState {
Recording,
Paused,
WarningDetected,
}
-51
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
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
-33
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/"]
}
);
-37
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"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
-3
View File
@@ -1,3 +0,0 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities"
-13
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 {};
-12
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>
-45
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';
}
});
});
-118
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.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.event_type.severity === "High" ? Severity.High :
event_json.event_type.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);
}
@@ -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>
@@ -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}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -1,38 +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">ZIP</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} {server_is_recording} />
{/each}
</div>
@@ -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>
-26
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,
}
-6
View File
@@ -1,6 +0,0 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
-86
View File
@@ -1,86 +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";
import ConfigForm from "$lib/components/ConfigForm.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/>
<ConfigForm />
{: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>
-4
View File
File diff suppressed because one or more lines are too long
-20
View File
@@ -1,20 +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
}),
version: {
// Use a deterministic version string for reproducible builds.
// Without this option, SvelteKit will use a timestamp.
name: process.env.GITHUB_SHA || 'dev'
}
}
};
-17
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;
-19
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
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "rayhunter-check"
version = "0.6.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"] }
simple_logger = "5.0.0"
walkdir = "2.5.0"
+221
View File
@@ -0,0 +1,221 @@
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)]
path: PathBuf,
#[arg(short = 'P', long)]
pcapify: bool,
#[arg(long)]
show_skipped: bool,
#[arg(short, long)]
quiet: bool,
#[arg(short, long)]
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
};
simple_logger::SimpleLogger::new()
.with_colors(true)
.without_timestamps()
.with_level(level)
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
.with_module_level("asn1_codecs", log::LevelFilter::Error)
.init()
.unwrap();
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;
}
}
}
+10 -23
View File
@@ -1,29 +1,14 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.4.0" version = "0.6.1"
edition = "2021" 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"]
wingtech = ["rayhunter/wingtech"]
default = ["orbic"]
[[bin]]
name = "rayhunter-daemon"
path = "src/daemon.rs"
[[bin]]
name = "rayhunter-check"
path = "src/check.rs"
[dependencies] [dependencies]
rayhunter = { path = "../lib" } rayhunter = { path = "../lib" }
toml = "0.8.8" toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] } 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"] } axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
thiserror = "1.0.52" thiserror = "1.0.52"
libc = "0.2.150" libc = "0.2.150"
@@ -32,14 +17,16 @@ env_logger = { version = "0.11", default-features = false }
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] } tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
futures-macro = "0.3.30" futures-macro = "0.3.30"
include_dir = "0.7.3" include_dir = "0.7.3"
mime_guess = "2.0.4"
chrono = { version = "0.4.31", features = ["serde"] } 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 } futures = { version = "0.3.30", default-features = false }
clap = { version = "4.5.2", features = ["derive"] }
serde_json = "1.0.114" serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] } image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1" tempfile = "3.10.1"
simple_logger = "5.0.0"
async_zip = { version = "0.0.17", features = ["tokio"] } async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98" anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false, features = [
"rustls-tls-webpki-roots-no-provider",
] }
rustls-rustcrypto = "0.0.2-alpha"
async-trait = "0.1.88"

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+38 -57
View File
@@ -1,5 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use std::{future, pin}; use std::{cmp, future, pin};
use axum::Json; use axum::Json;
use axum::{ use axum::{
@@ -7,8 +7,8 @@ use axum::{
http::StatusCode, http::StatusCode,
}; };
use futures::TryStreamExt; use futures::TryStreamExt;
use log::{debug, error, info}; use log::{error, info};
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness}; use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader; use rayhunter::qmdl::QmdlReader;
use serde::Serialize; use serde::Serialize;
@@ -18,14 +18,12 @@ use tokio::sync::mpsc::Receiver;
use tokio::sync::{RwLock, RwLockWriteGuard}; use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::dummy_analyzer::TestAnalyzer;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::server::ServerState; use crate::server::ServerState;
pub struct AnalysisWriter { pub struct AnalysisWriter {
writer: BufWriter<File>, writer: BufWriter<File>,
harness: Harness, harness: Harness,
bytes_written: usize,
} }
// We write our analysis results to a file immediately to minimize the amount of // We write our analysis results to a file immediately to minimize the amount of
@@ -35,19 +33,11 @@ pub struct AnalysisWriter {
// lets us simply append new rows to the end without parsing the entire JSON // lets us simply append new rows to the end without parsing the entire JSON
// object beforehand. // object beforehand.
impl AnalysisWriter { impl AnalysisWriter {
pub async fn new( pub async fn new(file: File, analyzer_config: &AnalyzerConfig) -> Result<Self, std::io::Error> {
file: File, let harness = Harness::new_with_config(analyzer_config);
enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig,
) -> Result<Self, std::io::Error> {
let mut harness = Harness::new_with_config(analyzer_config);
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
}
let mut result = Self { let mut result = Self {
writer: BufWriter::new(file), writer: BufWriter::new(file),
bytes_written: 0,
harness, harness,
}; };
let metadata = result.harness.get_metadata(); let metadata = result.harness.get_metadata();
@@ -56,22 +46,25 @@ impl AnalysisWriter {
} }
// Runs the analysis harness on the given container, serializing the results // 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( pub async fn analyze(
&mut self, &mut self,
container: MessagesContainer, container: MessagesContainer,
) -> Result<(usize, bool), std::io::Error> { ) -> Result<EventType, std::io::Error> {
let row = self.harness.analyze_qmdl_messages(container); let mut max_type = EventType::Informational;
if !row.is_empty() {
self.write(&row).await?; 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> { async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
let mut value_str = serde_json::to_string(value).unwrap(); let mut value_str = serde_json::to_string(value).unwrap();
value_str.push('\n'); value_str.push('\n');
self.bytes_written += value_str.len();
self.writer.write_all(value_str.as_bytes()).await?; self.writer.write_all(value_str.as_bytes()).await?;
self.writer.flush().await?; self.writer.flush().await?;
Ok(()) Ok(())
@@ -134,64 +127,58 @@ async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus
async fn perform_analysis( async fn perform_analysis(
name: &str, name: &str,
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig, analyzer_config: &AnalyzerConfig,
) -> Result<(), String> { ) -> Result<(), String> {
info!("Opening QMDL and analysis file for {}...", name); info!("Opening QMDL and analysis file for {name}...");
let (analysis_file, qmdl_file, entry_index) = { let (analysis_file, qmdl_file) = {
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
let (entry_index, _) = qmdl_store let (entry_index, _) = qmdl_store
.entry_for_name(name) .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 let analysis_file = qmdl_store
.clear_and_open_entry_analysis(entry_index) .clear_and_open_entry_analysis(entry_index)
.await .await
.map_err(|e| format!("{:?}", e))?; .map_err(|e| format!("{e:?}"))?;
let qmdl_file = qmdl_store let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index) .open_entry_qmdl(entry_index)
.await .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 = let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
AnalysisWriter::new(analysis_file, enable_dummy_analyzer, analyzer_config) .await
.await .map_err(|e| format!("{e:?}"))?;
.map_err(|e| format!("{:?}", e))?;
let file_size = qmdl_file let file_size = qmdl_file
.metadata() .metadata()
.await .await
.expect("failed to get QMDL file metadata") .expect("failed to get QMDL file metadata")
.len(); .len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize)); let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin::pin!(qmdl_reader let mut qmdl_stream = pin::pin!(
.as_stream() qmdl_reader
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))); .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 while let Some(container) = qmdl_stream
.try_next() .try_next()
.await .await
.expect("failed getting QMDL container") .expect("failed getting QMDL container")
{ {
let (size_bytes, _) = analysis_writer let _ = analysis_writer
.analyze(container) .analyze(container)
.await .await
.map_err(|e| format!("{:?}", e))?; .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))?;
} }
analysis_writer analysis_writer
.close() .close()
.await .await
.map_err(|e| format!("{:?}", e))?; .map_err(|e| format!("{e:?}"))?;
info!("Analysis for {} complete!", name); info!("Analysis for {name} complete!");
Ok(()) Ok(())
} }
@@ -201,7 +188,6 @@ pub fn run_analysis_thread(
mut analysis_rx: Receiver<AnalysisCtrlMessage>, mut analysis_rx: Receiver<AnalysisCtrlMessage>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_status_lock: Arc<RwLock<AnalysisStatus>>, analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
) { ) {
task_tracker.spawn(async move { task_tracker.spawn(async move {
@@ -211,15 +197,10 @@ pub fn run_analysis_thread(
let count = queued_len(analysis_status_lock.clone()).await; let count = queued_len(analysis_status_lock.clone()).await;
for _ in 0..count { for _ in 0..count {
let name = dequeue_to_running(analysis_status_lock.clone()).await; let name = dequeue_to_running(analysis_status_lock.clone()).await;
if let Err(err) = perform_analysis( if let Err(err) =
&name, perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
qmdl_store_lock.clone(),
enable_dummy_analyzer,
&analyzer_config,
)
.await
{ {
error!("failed to analyze {}: {}", name, err); error!("failed to analyze {name}: {err}");
} }
finish_running_analysis(analysis_status_lock.clone()).await; finish_running_analysis(analysis_status_lock.clone()).await;
} }
@@ -280,7 +261,7 @@ pub async fn start_analysis(
.map_err(|e| { .map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to queue new analysis files: {:?}", e), format!("failed to queue new analysis files: {e:?}"),
) )
})?; })?;
} }
+7 -2
View File
@@ -1,5 +1,7 @@
use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use rayhunter::Device;
use rayhunter::analysis::analyzer::AnalyzerConfig; use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError; use crate::error::RayhunterError;
@@ -10,10 +12,11 @@ pub struct Config {
pub qmdl_store_path: String, pub qmdl_store_path: String,
pub port: u16, pub port: u16,
pub debug_mode: bool, pub debug_mode: bool,
pub device: Device,
pub ui_level: u8, pub ui_level: u8,
pub enable_dummy_analyzer: bool,
pub colorblind_mode: bool, pub colorblind_mode: bool,
pub key_input_mode: u8, pub key_input_mode: u8,
pub ntfy_url: Option<String>,
pub analyzers: AnalyzerConfig, pub analyzers: AnalyzerConfig,
} }
@@ -23,11 +26,12 @@ impl Default for Config {
qmdl_store_path: "/data/rayhunter/qmdl".to_string(), qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
port: 8080, port: 8080,
debug_mode: false, debug_mode: false,
device: Device::Orbic,
ui_level: 1, ui_level: 1,
enable_dummy_analyzer: false,
colorblind_mode: false, colorblind_mode: false,
key_input_mode: 0, key_input_mode: 0,
analyzers: AnalyzerConfig::default(), analyzers: AnalyzerConfig::default(),
ntfy_url: None,
} }
} }
} }
@@ -39,6 +43,7 @@ where
if let Ok(config_file) = tokio::fs::read_to_string(&path).await { if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?) Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
} else { } else {
warn!("unable to read config file, using default config");
Ok(Config::default()) Ok(Config::default())
} }
} }
+451
View File
@@ -0,0 +1,451 @@
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;
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) {
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(
"heuristic-warning".to_string(),
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())
}
+242
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::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::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,
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_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 {
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_style = display_style_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()).await,
3 => fb.draw_img(img.unwrap()).await,
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 id 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, 2, pattern).await;
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
}
});
}
+16
View File
@@ -0,0 +1,16 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::DisplayState;
pub fn update_ui(
_task_tracker: &TaskTracker,
_config: &config::Config,
_ui_shutdown_rx: oneshot::Receiver<()>,
_ui_update_rx: Receiver<DisplayState>,
) {
info!("Headless mode, not spawning UI.");
}
+26
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 },
}
@@ -1,6 +1,7 @@
use crate::config; use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState; 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::mpsc::Receiver;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -11,6 +12,7 @@ const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)] #[derive(Copy, Clone, Default)]
struct Framebuffer; struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer { impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions { fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset? // TODO actually poll for this, maybe w/ fbset?
@@ -20,16 +22,16 @@ 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(); let mut raw_buffer = Vec::new();
for (r, g, b) in buffer { for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8; let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3; rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3; rgb565 |= (b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes()); raw_buffer.extend(rgb565.to_le_bytes());
} }
std::fs::write(FB_PATH, &raw_buffer).unwrap(); tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
} }
} }
+81
View File
@@ -0,0 +1,81 @@
/// 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::sync::oneshot;
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,
mut ui_shutdown_rx: oneshot::Receiver<()>,
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 {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
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;
}
});
}
@@ -4,7 +4,7 @@ use tokio::sync::oneshot;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::config; use crate::config;
use crate::display::{tplink_framebuffer, tplink_onebit, DisplayState}; use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
use std::fs; use std::fs;
@@ -19,6 +19,8 @@ pub fn update_ui(
info!("Invisible mode, not spawning 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() { if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
info!("detected one-bit display"); 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, ui_shutdown_rx, ui_update_rx)
@@ -1,10 +1,11 @@
use std::fs::File; use async_trait::async_trait;
use std::io::Write;
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use crate::config; use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState; use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -24,6 +25,7 @@ struct fb_fillrect {
rop: u32, rop: u32,
} }
#[async_trait]
impl GenericFramebuffer for Framebuffer { impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions { fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset? // 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 // for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
let dimensions = self.dimensions(); let dimensions = self.dimensions();
let width = dimensions.width; let width = dimensions.width;
let height = buffer.len() as u32 / 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 { let mut arg = fb_fillrect {
dx: 0, dx: 0,
dy: 0, dy: 0,
@@ -50,15 +52,16 @@ impl GenericFramebuffer for Framebuffer {
let mut raw_buffer = Vec::new(); let mut raw_buffer = Vec::new();
for (r, g, b) in buffer { for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8; let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3; rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3; rgb565 |= (b as u16) >> 3;
// note: big-endian! // note: big-endian!
raw_buffer.extend(rgb565.to_be_bytes()); 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 { unsafe {
let res = libc::ioctl( let res = libc::ioctl(
f.as_raw_fd(), f.as_raw_fd(),
@@ -68,7 +71,7 @@ impl GenericFramebuffer for Framebuffer {
); );
if res < 0 { if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res); panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
} }
} }
} }
@@ -10,8 +10,6 @@ use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError; use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use std::fs;
use std::thread::sleep;
use std::time::Duration; use std::time::Duration;
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer"; pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
@@ -122,7 +120,7 @@ pub fn update_ui(
info!("Invisible mode, not spawning UI."); info!("Invisible mode, not spawning UI.");
} }
task_tracker.spawn_blocking(move || { task_tracker.spawn(async move {
let mut pixels = STATUS_SMILING; let mut pixels = STATUS_SMILING;
loop { loop {
@@ -138,7 +136,7 @@ pub fn update_ui(
match ui_update_rx.try_recv() { match ui_update_rx.try_recv() {
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED, Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
Ok(DisplayState::Recording) => pixels = STATUS_SMILING, 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(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => { Err(e) => {
error!("error receiving framebuffer update message: {e}"); error!("error receiving framebuffer update message: {e}");
@@ -147,13 +145,13 @@ pub fn update_ui(
// we write the status every second because it may have been overwritten through menu // we write the status every second because it may have been overwritten through menu
// navigation. // navigation.
if display_level != 0 { if display_level != 0
if let Err(e) = fs::write(OLED_PATH, &pixels) { && let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
error!("failed to write to display: {e}"); {
} error!("failed to write to display: {e}");
} }
sleep(Duration::from_millis(1000)); tokio::time::sleep(Duration::from_millis(1000)).await;
} }
}); });
} }
+89
View File
@@ -0,0 +1,89 @@
/// 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::sync::oneshot;
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,
mut ui_shutdown_rx: oneshot::Receiver<()>,
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 {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
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;
}
});
}
@@ -1,12 +1,13 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
/// Display support for the Wingtech CT2MHS01 hotspot. /// Display support for the Wingtech CT2MHS01 hotspot.
/// ///
/// Tested on (from `/etc/wt_version`): /// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP /// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 /// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
/// WT_HARDWARE_VERSION=89323_1_20 /// WT_HARDWARE_VERSION=89323_1_20
use crate::config; use async_trait::async_trait;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -17,6 +18,7 @@ const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)] #[derive(Copy, Clone, Default)]
struct Framebuffer; struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer { impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions { fn dimensions(&self) -> Dimensions {
Dimensions { Dimensions {
@@ -25,16 +27,16 @@ 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(); let mut raw_buffer = Vec::new();
for (r, g, b) in buffer { for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8; let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3; rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3; rgb565 |= (b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes()); raw_buffer.extend(rgb565.to_le_bytes());
} }
std::fs::write(FB_PATH, &raw_buffer).unwrap(); tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
} }
} }
@@ -32,7 +32,7 @@ pub fn run_key_input_thread(
let mut file = match File::open("/dev/input/event0").await { let mut file = match File::open("/dev/input/event0").await {
Ok(file) => file, Ok(file) => file,
Err(e) => { Err(e) => {
error!("Failed to open /dev/input/event0: {}", e); error!("Failed to open /dev/input/event0: {e}");
return; return;
} }
}; };
@@ -49,7 +49,7 @@ pub fn run_key_input_thread(
} }
result = file.read_exact(&mut buffer) => { result = file.read_exact(&mut buffer) => {
if let Err(e) = result { if let Err(e) = result {
error!("failed to read key input: {}", e); error!("failed to read key input: {e}");
return; return;
} }
} }
@@ -61,11 +61,11 @@ pub fn run_key_input_thread(
// On orbic it was observed that pressing the power button can trigger many successive // On orbic it was observed that pressing the power button can trigger many successive
// events. Drop events that are too close together. // events. Drop events that are too close together.
if let Some(last_time) = last_event_time { if let Some(last_time) = last_event_time
if now.duration_since(last_time) < Duration::from_millis(50) { && now.duration_since(last_time) < Duration::from_millis(50)
last_event_time = Some(now); {
continue; last_event_time = Some(now);
} continue;
} }
last_event_time = Some(now); last_event_time = Some(now);
@@ -79,12 +79,12 @@ pub fn run_key_input_thread(
{ {
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await
{ {
error!("Failed to send StopRecording: {}", e); error!("Failed to send StopRecording: {e}");
} }
if let Err(e) = if let Err(e) =
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
{ {
error!("Failed to send StartRecording: {}", e); error!("Failed to send StartRecording: {e}");
} }
last_keyup = None; last_keyup = None;
continue; continue;
+44 -20
View File
@@ -2,43 +2,47 @@ mod analysis;
mod config; mod config;
mod diag; mod diag;
mod display; mod display;
mod dummy_analyzer;
mod error; mod error;
mod key_input; mod key_input;
mod notifications;
mod pcap; mod pcap;
mod qmdl_store; mod qmdl_store;
mod server; mod server;
mod stats; mod stats;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::config::{parse_args, parse_config}; use crate::config::{parse_args, parse_config};
use crate::diag::run_diag_read_thread; use crate::diag::run_diag_read_thread;
use crate::error::RayhunterError; use crate::error::RayhunterError;
use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap; use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::server::{get_config, get_qmdl, get_zip, serve_static, set_config, ServerState}; use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config,
};
use crate::stats::{get_qmdl_manifest, get_system_stats}; use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{ 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::response::Redirect;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router;
use diag::{ use diag::{
delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording, DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
DiagDeviceCtrlMessage, start_recording, stop_recording,
}; };
use log::{error, info}; use log::{error, info};
use qmdl_store::RecordingStoreError; use qmdl_store::RecordingStoreError;
use rayhunter::Device;
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::select; use tokio::select;
use tokio::sync::mpsc::{self, Sender}; use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{oneshot, RwLock}; use tokio::sync::{RwLock, oneshot};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
@@ -60,6 +64,7 @@ fn get_router() -> AppRouter {
.route("/api/analysis/{name}", post(start_analysis)) .route("/api/analysis/{name}", post(start_analysis))
.route("/api/config", get(get_config)) .route("/api/config", get(get_config))
.route("/api/config", post(set_config)) .route("/api/config", post(set_config))
.route("/api/debug/display-state", post(debug_set_display_state))
.route("/", get(|| async { Redirect::permanent("/index.html") })) .route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static)) .route("/{*path}", get(serve_static))
} }
@@ -93,7 +98,7 @@ async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
// Loads a RecordingStore if one exists, and if not, only create one if we're // 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 // 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> { async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?; let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
if config.debug_mode { if config.debug_mode {
@@ -108,9 +113,9 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
match RecordingStore::load(&config.qmdl_store_path).await { match RecordingStore::load(&config.qmdl_store_path).await {
Ok(store) => Ok(store), Ok(store) => Ok(store),
Err(RecordingStoreError::ParseManifestError(err)) => { Err(RecordingStoreError::ParseManifestError(err)) => {
error!("failed to parse QMDL manifest: {}", err); error!("failed to parse QMDL manifest: {err}");
info!("creating new empty manifest..."); info!("recovering manifest from existing QMDL files...");
Ok(RecordingStore::create(&config.qmdl_store_path).await?) Ok(RecordingStore::recover(&config.qmdl_store_path).await?)
} }
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
} }
@@ -122,6 +127,7 @@ 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, // 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 // trigger various cleanup tasks, including sending signals to other threads to
// shutdown // shutdown
#[allow(clippy::too_many_arguments)]
fn run_shutdown_thread( fn run_shutdown_thread(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
diag_device_sender: Sender<DiagDeviceCtrlMessage>, diag_device_sender: Sender<DiagDeviceCtrlMessage>,
@@ -139,14 +145,14 @@ fn run_shutdown_thread(
select! { select! {
res = tokio::signal::ctrl_c() => { res = tokio::signal::ctrl_c() => {
if let Err(err) = res { if let Err(err) = res {
error!("Unable to listen for shutdown signal: {}", err); error!("Unable to listen for shutdown signal: {err}");
} }
should_restart_flag.store(false, Ordering::Relaxed); should_restart_flag.store(false, Ordering::Relaxed);
} }
res = daemon_restart_rx => { res = daemon_restart_rx => {
if let Err(err) = res { if let Err(err) = res {
error!("Unable to listen for shutdown signal: {}", err); error!("Unable to listen for shutdown signal: {err}");
} }
should_restart_flag.store(true, Ordering::Relaxed); should_restart_flag.store(true, Ordering::Relaxed);
@@ -181,10 +187,14 @@ fn run_shutdown_thread(
}) })
} }
#[tokio::main] #[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), RayhunterError> { async fn main() -> Result<(), RayhunterError> {
env_logger::init(); env_logger::init();
rustls_rustcrypto::provider()
.install_default()
.expect("Couldn't install rustcrypto provider");
let args = parse_args(); let args = parse_args();
loop { loop {
@@ -212,10 +222,14 @@ async fn run_with_config(
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5); let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
let mut maybe_ui_shutdown_tx = None; let mut maybe_ui_shutdown_tx = None;
let mut maybe_key_input_shutdown_tx = None; let mut maybe_key_input_shutdown_tx = None;
let notification_service = NotificationService::new(config.ntfy_url.clone());
if !config.debug_mode { if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel(); let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx); 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 .await
.map_err(RayhunterError::DiagInitError)?; .map_err(RayhunterError::DiagInitError)?;
dev.config_logs() dev.config_logs()
@@ -227,14 +241,24 @@ async fn run_with_config(
&task_tracker, &task_tracker,
dev, dev,
diag_rx, diag_rx,
diag_tx.clone(),
ui_update_tx.clone(), ui_update_tx.clone(),
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_tx.clone(), analysis_tx.clone(),
config.enable_dummy_analyzer,
config.analyzers.clone(), config.analyzers.clone(),
notification_service.new_handler(),
); );
info!("Starting UI"); 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, ui_shutdown_rx, ui_update_rx);
info!("Starting Key Input service"); info!("Starting Key Input service");
let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel(); let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel();
@@ -255,7 +279,6 @@ async fn run_with_config(
analysis_rx, analysis_rx,
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_status_lock.clone(), analysis_status_lock.clone(),
config.enable_dummy_analyzer,
config.analyzers.clone(), config.analyzers.clone(),
); );
let should_restart_flag = Arc::new(AtomicBool::new(false)); let should_restart_flag = Arc::new(AtomicBool::new(false));
@@ -271,15 +294,16 @@ async fn run_with_config(
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_tx.clone(), analysis_tx.clone(),
); );
run_notification_worker(&task_tracker, notification_service);
let state = Arc::new(ServerState { let state = Arc::new(ServerState {
config_path: args.config_path.clone(), config_path: args.config_path.clone(),
config, config,
qmdl_store_lock: qmdl_store_lock.clone(), qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: diag_tx, diag_device_ctrl_sender: diag_tx,
ui_update_sender: ui_update_tx,
analysis_status_lock, analysis_status_lock,
analysis_sender: analysis_tx, analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))), daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
ui_update_sender: Some(ui_update_tx),
}); });
run_server(&task_tracker, state, server_shutdown_rx).await; run_server(&task_tracker, state, server_shutdown_rx).await;
+148
View File
@@ -0,0 +1,148 @@
use std::{
cmp::min,
collections::HashMap,
time::{Duration, Instant},
};
use log::error;
use tokio::sync::mpsc::{self, error::TryRecvError};
use tokio_util::task::TaskTracker;
pub struct Notification {
message_type: String,
message: String,
debounce: Option<Duration>,
}
impl Notification {
pub fn new(message_type: String, message: String, debounce: Option<Duration>) -> Self {
Notification {
message_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()
}
}
pub fn run_notification_worker(
task_tracker: &TaskTracker,
mut notification_service: NotificationService,
) {
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) => {
let status = notification_statuses
.entry(notification.message_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 http_client
.post(&url)
.body(notification.message.clone())
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
notification.last_sent = Some(Instant::now());
notification.failed_since_last_success = 0;
notification.needs_sending = false;
} else {
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
}
Err(e) => {
error!("Failed to send notification to ntfy: {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;
}
}
}
});
}
+6 -6
View File
@@ -3,8 +3,8 @@ use crate::ServerState;
use anyhow::Error; use anyhow::Error;
use axum::body::Body; use axum::body::Body;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use log::error; use log::error;
use rayhunter::diag::DataType; use rayhunter::diag::DataType;
@@ -12,7 +12,7 @@ use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter; use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::qmdl::QmdlReader; use rayhunter::qmdl::QmdlReader;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{duplex, AsyncRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite, duplex};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data // Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
@@ -28,7 +28,7 @@ pub async fn get_pcap(
} }
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or(( let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
format!("couldn't find manifest entry with name {}", qmdl_name), format!("couldn't find manifest entry with name {qmdl_name}"),
))?; ))?;
if entry.qmdl_size_bytes == 0 { if entry.qmdl_size_bytes == 0 {
return Err(( return Err((
@@ -40,14 +40,14 @@ pub async fn get_pcap(
let qmdl_file = qmdl_store let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index) .open_entry_qmdl(entry_index)
.await .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 // the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes) // (entry.size_bytes)
let (reader, writer) = duplex(1024); let (reader, writer) = duplex(1024);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await { if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
error!("failed to generate PCAP: {:?}", e); error!("failed to generate PCAP: {e:?}");
} }
}); });
@@ -84,7 +84,7 @@ where
.await?; .await?;
} }
} }
Err(e) => error!("error parsing message: {:?}", e), Err(e) => error!("error parsing message: {e:?}"),
} }
} }
} }
@@ -1,12 +1,14 @@
use std::io::{self, ErrorKind}; use std::io::{self, ErrorKind};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use log::{info, warn};
use rayhunter::util::RuntimeMetadata; use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use tokio::{ use tokio::{
fs::{self, try_exists, File, OpenOptions}, fs::{self, File, OpenOptions, try_exists},
io::AsyncWriteExt, io::AsyncWriteExt,
}; };
@@ -49,7 +51,6 @@ pub struct ManifestEntry {
pub start_time: DateTime<Local>, pub start_time: DateTime<Local>,
pub last_message_time: Option<DateTime<Local>>, pub last_message_time: Option<DateTime<Local>>,
pub qmdl_size_bytes: usize, pub qmdl_size_bytes: usize,
pub analysis_size_bytes: usize,
pub rayhunter_version: Option<String>, pub rayhunter_version: Option<String>,
pub system_os: Option<String>, pub system_os: Option<String>,
pub arch: Option<String>, pub arch: Option<String>,
@@ -64,7 +65,6 @@ impl ManifestEntry {
start_time: now, start_time: now,
last_message_time: None, last_message_time: None,
qmdl_size_bytes: 0, qmdl_size_bytes: 0,
analysis_size_bytes: 0,
rayhunter_version: Some(metadata.rayhunter_version), rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os), system_os: Some(metadata.system_os),
arch: Some(metadata.arch), arch: Some(metadata.arch),
@@ -138,6 +138,83 @@ impl RecordingStore {
Ok(store) 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> async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
where where
P: AsRef<Path>, P: AsRef<Path>,
@@ -202,7 +279,6 @@ impl RecordingStore {
.open(entry.get_analysis_filepath(&self.path)) .open(entry.get_analysis_filepath(&self.path))
.await .await
.map_err(RecordingStoreError::ReadFileError)?; .map_err(RecordingStoreError::ReadFileError)?;
self.update_entry_analysis_size(entry_index, 0).await?;
Ok(file) Ok(file)
} }
@@ -228,17 +304,9 @@ impl RecordingStore {
self.write_manifest().await 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.write_manifest().await
}
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> { 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 tmp_path = self.path.join("manifest.toml.new");
let mut manifest_tmp_file = File::create(&tmp_path) let mut manifest_tmp_file = File::create(&tmp_path)
.await .await
@@ -273,20 +341,32 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index])) 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 let entry_to_delete_idx = self
.manifest .manifest
.entries .entries
.iter() .iter()
.position(|entry| entry.name == name) .position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?; .ok_or(RecordingStoreError::NoSuchEntryError)?;
if let Some(current_entry) = self.current_entry { match self.current_entry {
if current_entry == entry_to_delete_idx { Some(current_entry) if current_entry == entry_to_delete_idx => {
self.close_current_entry().await?; self.close_current_entry().await?;
} else { }
Some(current_entry) => {
self.current_entry = Some(current_entry - 1); self.current_entry = Some(current_entry - 1);
} }
} None => {}
};
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx); let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?; self.write_manifest().await?;
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path); let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
@@ -297,7 +377,7 @@ impl RecordingStore {
remove_file_if_exists(&analysis_filepath) remove_file_if_exists(&analysis_filepath)
.await .await
.map_err(RecordingStoreError::DeleteFileError)?; .map_err(RecordingStoreError::DeleteFileError)?;
Ok(entry_to_delete) Ok(())
} }
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> { pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
@@ -369,9 +449,11 @@ mod tests {
RecordingStore::read_manifest(dir.path()).await.unwrap(), RecordingStore::read_manifest(dir.path()).await.unwrap(),
store.manifest store.manifest
); );
assert!(store.manifest.entries[entry_index] assert!(
.last_message_time store.manifest.entries[entry_index]
.is_none()); .last_message_time
.is_none()
);
store store
.update_entry_qmdl_size(entry_index, 1000) .update_entry_qmdl_size(entry_index, 1000)
+73 -35
View File
@@ -1,39 +1,39 @@
use anyhow::Error; use anyhow::Error;
use async_zip::tokio::write::ZipFileWriter;
use async_zip::Compression; use async_zip::Compression;
use async_zip::ZipEntryBuilder; use async_zip::ZipEntryBuilder;
use async_zip::tokio::write::ZipFileWriter;
use axum::Json;
use axum::body::Body; use axum::body::Body;
use axum::extract::Path; use axum::extract::Path;
use axum::extract::State; use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE}; use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode}; use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json; use log::{error, warn};
use include_dir::{include_dir, Dir};
use log::error;
use std::sync::Arc; use std::sync::Arc;
use tokio::fs::write; use tokio::fs::write;
use tokio::io::{copy, duplex, AsyncReadExt}; use tokio::io::{AsyncReadExt, copy, duplex};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio::sync::{oneshot, RwLock}; use tokio::sync::{RwLock, oneshot};
use tokio_util::compat::FuturesAsyncWriteCompatExt; use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::DiagDeviceCtrlMessage;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config; use crate::config::Config;
use crate::display::DisplayState;
use crate::pcap::generate_pcap_data; use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::{display, DiagDeviceCtrlMessage};
pub struct ServerState { pub struct ServerState {
pub config_path: String, pub config_path: String,
pub config: Config, pub config: Config,
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>, pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>, pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>, pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>, pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>, pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
pub ui_update_sender: Option<Sender<DisplayState>>,
} }
pub async fn get_qmdl( pub async fn get_qmdl(
@@ -44,7 +44,7 @@ pub async fn get_qmdl(
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or(( let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {}", qmdl_idx), format!("couldn't find qmdl file with name {qmdl_idx}"),
))?; ))?;
let qmdl_file = qmdl_store let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index) .open_entry_qmdl(entry_index)
@@ -52,7 +52,7 @@ pub async fn get_qmdl(
.map_err(|err| { .map_err(|err| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {}", err), format!("error opening QMDL file: {err}"),
) )
})?; })?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64); let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
@@ -66,29 +66,45 @@ pub async fn get_qmdl(
Ok((headers, body).into_response()) 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( pub async fn serve_static(
State(_): State<Arc<ServerState>>, State(_): State<Arc<ServerState>>,
Path(path): Path<String>, Path(path): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let path = path.trim_start_matches('/'); let path = path.trim_start_matches('/');
let mime_type = mime_guess::from_path(path).first_or_text_plain();
match STATIC_DIR.get_file(path) { match path {
None => Response::builder() "rayhunter_icon.png" => (
.status(StatusCode::NOT_FOUND) [(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
.body(Body::empty()) include_bytes!("../web/build/rayhunter_icon.png"),
.unwrap(), )
Some(file) => Response::builder() .into_response(),
.status(StatusCode::OK) "rayhunter_orca_only.png" => (
.header( [(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
header::CONTENT_TYPE, include_bytes!("../web/build/rayhunter_orca_only.png"),
HeaderValue::from_str(mime_type.as_ref()).unwrap(), )
) .into_response(),
.body(Body::from(file.contents())) "rayhunter_text.png" => (
.unwrap(), [(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()
}
} }
} }
@@ -105,14 +121,14 @@ pub async fn set_config(
let config_str = toml::to_string_pretty(&config).map_err(|err| { let config_str = toml::to_string_pretty(&config).map_err(|err| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to serialize config as TOML: {}", err), format!("failed to serialize config as TOML: {err}"),
) )
})?; })?;
write(&state.config_path, config_str).await.map_err(|err| { write(&state.config_path, config_str).await.map_err(|err| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to write config file: {}", err), format!("failed to write config file: {err}"),
) )
})?; })?;
@@ -146,7 +162,7 @@ pub async fn get_zip(
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or(( let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
format!("couldn't find entry with name {}", qmdl_idx), format!("couldn't find entry with name {qmdl_idx}"),
))?; ))?;
if entry.qmdl_size_bytes == 0 { if entry.qmdl_size_bytes == 0 {
@@ -207,7 +223,7 @@ pub async fn get_zip(
{ {
// if we fail to generate the PCAP file, we should still continue and give the // if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL. // user the QMDL.
error!("Failed to generate PCAP: {:?}", e); error!("Failed to generate PCAP: {e:?}");
} }
entry_writer.into_inner().close().await?; entry_writer.into_inner().close().await?;
@@ -219,7 +235,7 @@ pub async fn get_zip(
.await; .await;
if let Err(e) = result { if let Err(e) = result {
error!("Error generating ZIP file: {:?}", e); error!("Error generating ZIP file: {e:?}");
} }
}); });
@@ -228,6 +244,29 @@ pub async fn get_zip(
Ok((headers, body).into_response()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -278,12 +317,11 @@ mod tests {
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>, store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
) -> Arc<ServerState> { ) -> Arc<ServerState> {
let (tx, _rx) = tokio::sync::mpsc::channel(1); let (tx, _rx) = tokio::sync::mpsc::channel(1);
let (ui_tx, _ui_rx) = tokio::sync::mpsc::channel(1);
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1); let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
let analysis_status = { let analysis_status = {
let store = store_lock.try_read().unwrap(); let store = store_lock.try_read().unwrap();
crate::analysis::AnalysisStatus::new(&*store) crate::analysis::AnalysisStatus::new(&store)
}; };
Arc::new(ServerState { Arc::new(ServerState {
@@ -291,10 +329,10 @@ mod tests {
config: Config::default(), config: Config::default(),
qmdl_store_lock: store_lock, qmdl_store_lock: store_lock,
diag_device_ctrl_sender: tx, diag_device_ctrl_sender: tx,
ui_update_sender: ui_tx,
analysis_status_lock: Arc::new(RwLock::new(analysis_status)), analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
analysis_sender: analysis_tx, analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(None)), daemon_restart_tx: Arc::new(RwLock::new(None)),
ui_update_sender: None,
}) })
} }
+31 -15
View File
@@ -3,11 +3,11 @@ use std::sync::Arc;
use crate::qmdl_store::ManifestEntry; use crate::qmdl_store::ManifestEntry;
use crate::server::ServerState; use crate::server::ServerState;
use axum::Json;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use log::error; use log::error;
use rayhunter::util::RuntimeMetadata; use rayhunter::{Device, util::RuntimeMetadata};
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
@@ -19,10 +19,10 @@ pub struct SystemStats {
} }
impl SystemStats { 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 { Ok(Self {
disk_stats: DiskStats::new(qmdl_path).await?, disk_stats: DiskStats::new(qmdl_path, device).await?,
memory_stats: MemoryStats::new().await?, memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(), runtime_metadata: RuntimeMetadata::new(),
}) })
} }
@@ -40,13 +40,22 @@ pub struct DiskStats {
impl DiskStats { impl DiskStats {
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing // runs "df -h <qmdl_path>" to get storage statistics for the partition containing
// the QMDL file // the QMDL file.
pub async fn new(qmdl_path: &str) -> Result<Self, String> { pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
let mut df_cmd = Command::new("df"); // 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("-h");
df_cmd.arg(qmdl_path); df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?; 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 { Ok(Self {
partition: parts.next().ok_or("error parsing df output")?.to_string(), partition: parts.next().ok_or("error parsing df output")?.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(), total_size: parts.next().ok_or("error parsing df output")?.to_string(),
@@ -83,9 +92,16 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
} }
impl MemoryStats { impl MemoryStats {
// runs "free -k" and parses the output to retrieve memory stats // runs "free -k" and parses the output to retrieve memory stats for most devices,
pub async fn new() -> Result<Self, String> { pub async fn new(device: &Device) -> Result<Self, String> {
let mut free_cmd = Command::new("free"); // 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"); free_cmd.arg("-k");
let stdout = get_cmd_output(free_cmd).await?; let stdout = get_cmd_output(free_cmd).await?;
let mut numbers = stdout let mut numbers = stdout
@@ -102,7 +118,7 @@ impl MemoryStats {
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M") // turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
fn humanize_kb(kb: usize) -> String { fn humanize_kb(kb: usize) -> String {
if kb < 1000 { if kb < 1000 {
return format!("{}K", kb); return format!("{kb}K");
} }
format!("{:.1}M", kb as f64 / 1024.0) format!("{:.1}M", kb as f64 / 1024.0)
} }
@@ -111,10 +127,10 @@ pub async fn get_system_stats(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<Json<SystemStats>, (StatusCode, String)> { ) -> Result<Json<SystemStats>, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await; 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)), Ok(stats) => Ok(Json(stats)),
Err(err) => { Err(err) => {
error!("error getting system stats: {}", err); error!("error getting system stats: {err}");
Err(( Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"error getting system stats".to_string(), "error getting system stats".to_string(),
@@ -2,3 +2,6 @@
package-lock.json package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
# Static Assets
static/pico.min.css
+15
View File
@@ -0,0 +1,15 @@
{
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+42
View File
@@ -0,0 +1,42 @@
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',
},
}
);
+38
View File
@@ -0,0 +1,38 @@
{
"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.13.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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+3
View File
@@ -0,0 +1,3 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
+13
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
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">
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
</body>
</html>
@@ -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);
}
@@ -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';
}
});
});
+138
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);
}
@@ -1,6 +1,5 @@
import { get_report, type AnalysisReport } from "./analysis.svelte"; import { get_report, type AnalysisReport } from './analysis.svelte';
import type { Manifest, ManifestEntry } from "./manifest.svelte"; import { req } from './utils.svelte';
import { req } from "./utils.svelte";
export enum AnalysisStatus { export enum AnalysisStatus {
// rayhunter is currently analyzing this entry (note that this is distinct // rayhunter is currently analyzing this entry (note that this is distinct
@@ -19,16 +18,14 @@ type AnalysisStatusJson = {
}; };
export type AnalysisResult = { export type AnalysisResult = {
name: string, name: string;
status: AnalysisStatus, status: AnalysisStatus;
}; };
export class AnalysisManager { export class AnalysisManager {
public status: Map<string, AnalysisStatus> = new Map(); public status: Map<string, AnalysisStatus> = $state(new Map());
public reports: Map<string, AnalysisReport | string> = new Map(); public reports: Map<string, AnalysisReport | string> = $state(new Map());
public set_queued_status(name: string) {
public async run_analysis(name: string) {
await req('POST', `/api/analysis/${name}`);
this.status.set(name, AnalysisStatus.Queued); this.status.set(name, AnalysisStatus.Queued);
this.reports.delete(name); this.reports.delete(name);
} }
@@ -53,11 +50,13 @@ export class AnalysisManager {
// fetch the analysis report // fetch the analysis report
this.reports.delete(entry); this.reports.delete(entry);
get_report(entry).then(report => { get_report(entry)
this.reports.set(entry, report); .then((report) => {
}).catch(err => { this.reports.set(entry, report);
this.reports.set(entry, `Failed to get analysis: ${err}`); })
}); .catch((err) => {
this.reports.set(entry, `Failed to get analysis: ${err}`);
});
} }
} }
} }
@@ -0,0 +1,86 @@
<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">
<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>
<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}
@@ -0,0 +1,92 @@
<script lang="ts">
import { AnalysisStatus } from '$lib/analysisManager.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 {
return `${entry.analysis_report.statistics.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.by(() => {
if (!ready) {
return 'text-gray-700';
} else if ((entry.get_num_warnings() || 0) < 1) {
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
} else {
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
}
});
</script>
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
<span class="flex flex-row items-center gap-1">
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)}
<svg
class="animate-spin h-4 w-4 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{/if}
<span class={button_class}>{summary}</span>
</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>
@@ -0,0 +1,102 @@
<script lang="ts">
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte';
let {
report,
}: {
report: AnalysisReport;
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: 'long',
dateStyle: 'short',
});
const analyzers = report.metadata.analyzers;
const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map();
for (const row of report.rows) {
if (row.type === AnalysisRowType.Skipped) {
let count = map.get(row.reason);
if (count === undefined) {
count = 0;
}
map.set(row.reason, 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}
<div class="overflow-x-scroll">
<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">Heuristic</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row}
{#if row.type === AnalysisRowType.Analysis}
{@const parsed_date = new Date(row.packet_timestamp)}
{#each row.events as event, analyzerIndex}
{#if event !== null}
{@const analyzer = analyzers[analyzerIndex]}
{@const event_type_class = {
Informational: '',
Low: 'bg-yellow-200',
Medium: 'bg-orange-400',
High: 'bg-red-600',
}[event.event_type]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {event_type_class} text-center"
>{event.event_type}</td
>
</tr>
{/if}
{/each}
{/if}
{/each}
</tbody>
</table>
</div>
{/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>
<div class="overflow-x-scroll">
<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>
</div>
{/if}
@@ -0,0 +1,53 @@
<script lang="ts">
import { type ReportMetadata } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import AnalysisTable from './AnalysisTable.svelte';
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
let {
entry,
manager,
current,
}: {
entry: ManifestEntry;
manager: AnalysisManager;
current: boolean;
} = $props();
</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 !current}
<div class="flex flex-row justify-end items-center">
<ReAnalyzeButton {entry} {manager} />
</div>
{/if}
{#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>
@@ -0,0 +1,97 @@
<script lang="ts">
import { user_action_req } from '$lib/utils.svelte';
let {
url,
method = 'POST',
label,
loadingLabel,
disabled = false,
variant = 'blue',
icon,
onclick,
ariaLabel,
errorMessage,
}: {
url: string;
method?: string;
label: string;
loadingLabel?: string;
disabled?: boolean;
variant?: 'blue' | 'red' | 'green';
icon?: any; // Svelte snippet
onclick?: () => void | Promise<void>;
ariaLabel?: string;
errorMessage?: string;
} = $props();
let is_requesting = $state(false);
let is_disabled = $derived(disabled || is_requesting);
const variantClasses = {
blue: {
enabled: 'bg-blue-500 hover:bg-blue-700',
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
},
red: {
enabled: 'bg-red-500 hover:bg-red-700',
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
},
green: {
enabled: 'bg-green-500 hover:bg-green-700',
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
},
};
async function handleClick() {
if (is_disabled) return;
is_requesting = true;
try {
await user_action_req(
method,
url,
errorMessage ? errorMessage : 'Error performing action'
);
if (onclick) {
await onclick();
}
} catch (err) {
console.error(`Failed to ${method} ${url}:`, err);
alert(`Request failed. Please try again.`);
} finally {
is_requesting = false;
}
}
let buttonClasses = $derived(
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
);
</script>
<button
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
onclick={handleClick}
disabled={is_disabled}
aria-label={ariaLabel || label}
>
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
{#if is_requesting}
<svg
class="w-4 h-4 text-white animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if icon}
{@render icon()}
{/if}
</button>
@@ -5,19 +5,19 @@
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let message = $state(""); let message = $state('');
let messageType = $state<"success" | "error" | null>(null); let messageType = $state<'success' | 'error' | null>(null);
let showConfig = $state(false); let showConfig = $state(false);
async function loadConfig() { async function loadConfig() {
try { try {
loading = true; loading = true;
config = await get_config(); config = await get_config();
message = ""; message = '';
messageType = null; messageType = null;
} catch (error) { } catch (error) {
message = `Failed to load config: ${error}`; message = `Failed to load config: ${error}`;
messageType = "error"; messageType = 'error';
} finally { } finally {
loading = false; loading = false;
} }
@@ -25,21 +25,21 @@
async function saveConfig() { async function saveConfig() {
if (!config) return; if (!config) return;
try { try {
saving = true; saving = true;
await set_config(config); await set_config(config);
message = "Config saved successfully! Rayhunter is restarting now. Reload the page in a few seconds."; message =
messageType = "success"; 'Config saved successfully! Rayhunter is restarting now. Reload the page in a few seconds.';
messageType = 'success';
} catch (error) { } catch (error) {
message = `Failed to save config: ${error}`; message = `Failed to save config: ${error}`;
messageType = "error"; messageType = 'error';
} finally { } finally {
saving = false; saving = false;
} }
} }
// Load config when first shown // Load config when first shown
$effect(() => { $effect(() => {
if (showConfig && !config) { if (showConfig && !config) {
@@ -49,21 +49,33 @@
</script> </script>
<div class="bg-white rounded-lg shadow-md p-6 m-4"> <div class="bg-white rounded-lg shadow-md p-6 m-4">
<button <button
class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue" class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue"
onclick={() => showConfig = !showConfig} onclick={() => (showConfig = !showConfig)}
> >
<span>Configuration</span> <span>Configuration</span>
<svg class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg> </svg>
</button> </button>
{#if showConfig} {#if showConfig}
{#if loading} {#if loading}
<div class="text-center py-4">Loading config...</div> <div class="text-center py-4">Loading config...</div>
{:else if config} {:else if config}
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); saveConfig(); }}> <form
class="space-y-4"
onsubmit={(e) => {
e.preventDefault();
saveConfig();
}}
>
<div> <div>
<label for="ui_level" class="block text-sm font-medium text-gray-700 mb-1"> <label for="ui_level" class="block text-sm font-medium text-gray-700 mb-1">
Device UI Level Device UI Level
@@ -81,7 +93,10 @@
</div> </div>
<div> <div>
<label for="key_input_mode" class="block text-sm font-medium text-gray-700 mb-1"> <label
for="key_input_mode"
class="block text-sm font-medium text-gray-700 mb-1"
>
Device Input Mode Device Input Mode
</label> </label>
<select <select
@@ -90,10 +105,24 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
> >
<option value={0}>0 - Disable button control</option> <option value={0}>0 - Disable button control</option>
<option value={1}>1 - Double-tap power button to start/stop recording</option> <option value={1}
>1 - Double-tap power button to start/stop recording</option
>
</select> </select>
</div> </div>
<div>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
ntfy URL for Sending Notifications
</label>
<input
id="ntfy_url"
type="url"
bind:value={config.ntfy_url}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center"> <div class="flex items-center">
<input <input
@@ -109,7 +138,9 @@
</div> </div>
<div class="border-t pt-4 mt-6"> <div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Analyzer Heuristic Settings</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings
</h3>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center"> <div class="flex items-center">
<input <input
@@ -130,7 +161,10 @@
bind:checked={config.analyzers.connection_redirect_2g_downgrade} bind:checked={config.analyzers.connection_redirect_2g_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/> />
<label for="connection_redirect_2g_downgrade" class="ml-2 block text-sm text-gray-700"> <label
for="connection_redirect_2g_downgrade"
class="ml-2 block text-sm text-gray-700"
>
Connection Redirect 2G Downgrade Heuristic Connection Redirect 2G Downgrade Heuristic
</label> </label>
</div> </div>
@@ -142,7 +176,10 @@
bind:checked={config.analyzers.lte_sib6_and_7_downgrade} bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/> />
<label for="lte_sib6_and_7_downgrade" class="ml-2 block text-sm text-gray-700"> <label
for="lte_sib6_and_7_downgrade"
class="ml-2 block text-sm text-gray-700"
>
LTE SIB6 and SIB7 Downgrade Heuristic LTE SIB6 and SIB7 Downgrade Heuristic
</label> </label>
</div> </div>
@@ -158,6 +195,42 @@
Null Cipher Heuristic Null Cipher Heuristic
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="nas_null_cipher"
type="checkbox"
bind:checked={config.analyzers.nas_null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
NAS Null Cipher Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="incomplete_sib"
type="checkbox"
bind:checked={config.analyzers.incomplete_sib}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="test_analyzer"
type="checkbox"
bind:checked={config.analyzers.test_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisey!)
</label>
</div>
</div> </div>
</div> </div>
@@ -168,20 +241,35 @@
class="bg-blue-500 hover:bg-blue-700 disabled:opacity-50 text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center" class="bg-blue-500 hover:bg-blue-700 disabled:opacity-50 text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
> >
{#if saving} {#if saving}
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div> <div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
Saving... Saving...
{:else} {:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg> </svg>
Apply and restart Apply and restart
{/if} {/if}
</button> </button>
</div> </div>
</form> </form>
{#if message} {#if message}
<div class="mt-4 p-3 rounded {messageType === 'error' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}"> <div
class="mt-4 p-3 rounded {messageType === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}"
>
{message} {message}
</div> </div>
{/if} {/if}
@@ -0,0 +1,12 @@
<script lang="ts">
import DeleteButton from './DeleteButton.svelte';
</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`}
name="all recodings"
/>
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { user_action_req } from '$lib/utils.svelte';
let {
text,
url,
prompt,
name,
}: {
text?: string;
url: string;
prompt: string;
name: string;
} = $props();
function confirmDelete() {
if (window.confirm(prompt)) {
user_action_req('POST', url, 'Unable to delete recording ' + name);
}
}
</script>
<button
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-2 sm: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>
@@ -0,0 +1,23 @@
<script lang="ts">
let {
url,
text,
full_button = false,
}: {
url: string;
text: string;
full_button?: boolean;
} = $props();
</script>
<a
href={url}
class="flex flex-row {full_button
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md'
: 'text-blue-600 underline'}"
>
{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>
</a>
@@ -1,75 +1,101 @@
<script lang="ts"> <script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte"; import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte'; import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from "$lib/components/DeleteButton.svelte"; import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from "./AnalysisStatus.svelte"; import AnalysisStatus from './AnalysisStatus.svelte';
import AnalysisView from "./AnalysisView.svelte"; import AnalysisView from './AnalysisView.svelte';
import RecordingControls from "./RecordingControls.svelte"; import RecordingControls from './RecordingControls.svelte';
let { entry, current, i, server_is_recording }: { let {
entry,
current,
server_is_recording,
manager,
}: {
entry: ManifestEntry; entry: ManifestEntry;
current: boolean; current: boolean;
i: number;
server_is_recording: boolean; server_is_recording: boolean;
manager: AnalysisManager;
} = $props(); } = $props();
// passing `undefined` as the locale uses the browser default // passing `undefined` as the locale uses the browser default
const date_formatter = new Intl.DateTimeFormat(undefined, { const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long", timeStyle: 'long',
dateStyle: "short", dateStyle: 'short',
}); });
let status_row_color = $derived.by(() => { let status_row_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings(); const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) { if (num_warnings !== undefined && num_warnings > 0) {
return "bg-red-100"; return 'bg-red-100';
} }
return current ? "bg-green-100" : "bg-gray-100" return current ? 'bg-green-100' : 'bg-gray-100';
}); });
let status_border_color = $derived.by(() => { let status_border_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings(); const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) { if (num_warnings !== undefined && num_warnings > 0) {
return "border-red-100"; return 'border-red-100';
} }
return current ? "border-green-100" : "border-gray-100" return current ? 'border-green-100' : 'border-gray-100';
}); });
let analysis_visible = $state(false); let analysis_visible = $state(false);
function toggle_analysis_visibility() { function toggle_analysis_visibility() {
analysis_visible = !analysis_visible; analysis_visible = !analysis_visible;
} }
</script> </script>
<div class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1">
<div
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-scroll overflow-y-hidden"
>
{#if current} {#if current}
<div class="flex flex-row justify-between gap-2"> <div class="flex flex-row justify-between gap-2">
<span class="text-xl mb-2">Current Recording</span> <span class="text-xl mb-2">Current Recording</span>
<span class=""><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></span> <span class=""
><AnalysisStatus
onclick={toggle_analysis_visibility}
{entry}
{analysis_visible}
/></span
>
</div> </div>
{/if} {/if}
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<span class="font-bold">ID: {entry.name}</span> <span class="font-bold">ID: {entry.name}</span>
{#if !current} {#if !current}
<span class=""><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></span> <span class=""
><AnalysisStatus
onclick={toggle_analysis_visibility}
{entry}
{analysis_visible}
/></span
>
{/if} {/if}
</div> </div>
<span class="">{entry.get_readable_qmdl_size()}</span> <span class="">{entry.get_readable_qmdl_size()}</span>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="">Start: {date_formatter.format(entry.start_time)}</span> <span class="">Start: {date_formatter.format(entry.start_time)}</span>
<span class="">Last Message: {entry.last_message_time && date_formatter.format(entry.last_message_time) || "N/A"}</span> <span class=""
>Last Message: {(entry.last_message_time &&
date_formatter.format(entry.last_message_time)) ||
'N/A'}</span
>
</div> </div>
<div class="flex flex-row justify-between lg:justify-end gap-2 mt-2"> <div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-scroll">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button=true /> <DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button=true /> <DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
<DownloadLink url={entry.get_zip_url()} text="zip" full_button=true /> <DownloadLink url={entry.get_zip_url()} text="zip" full_button />
{#if current} {#if current}
<RecordingControls {server_is_recording} /> <RecordingControls {server_is_recording} />
{:else} {:else}
<DeleteButton <DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`} prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()} url={entry.get_delete_url()}
name={entry.name}
/> />
{/if} {/if}
</div> </div>
<div class="border-b {analysis_visible ? '' : 'hidden'}"> <div class="border-b {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} /> <AnalysisView {entry} {manager} {current} />
</div> </div>
</div> </div>
@@ -0,0 +1,38 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import TableRow from './ManifestTableRow.svelte';
import Card from './ManifestCard.svelte';
interface Props {
entries: ManifestEntry[];
server_is_recording: boolean;
manager: AnalysisManager;
}
let { entries, server_is_recording, manager }: 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">Download</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} {manager} />
{/each}
</tbody>
</table>
<!--For smaller screens we use cards-->
<div class="lg:hidden flex flex-col gap-4">
{#each entries as entry}
<Card {entry} current={false} {server_is_recording} {manager} />
{/each}
</div>
@@ -1,27 +1,34 @@
<script lang="ts"> <script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte"; import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte'; import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from "$lib/components/DeleteButton.svelte"; import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from "./AnalysisStatus.svelte"; import AnalysisStatus from './AnalysisStatus.svelte';
import AnalysisView from "./AnalysisView.svelte"; import AnalysisView from './AnalysisView.svelte';
let { entry, current, i }: { let {
entry,
current,
i,
manager,
}: {
entry: ManifestEntry; entry: ManifestEntry;
current: boolean; current: boolean;
i: number; i: number;
manager: AnalysisManager;
} = $props(); } = $props();
// passing `undefined` as the locale uses the browser default // passing `undefined` as the locale uses the browser default
const date_formatter = new Intl.DateTimeFormat(undefined, { const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long", timeStyle: 'long',
dateStyle: "short", dateStyle: 'short',
}); });
let alternating_row_color = $derived(i % 2 == 0 ? "bg-white" : "bg-gray-100"); let alternating_row_color = $derived(i % 2 == 0 ? 'bg-white' : 'bg-gray-100');
let status_row_color = $derived.by(() => { let status_row_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings(); const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) { if (num_warnings !== undefined && num_warnings > 0) {
return "bg-red-100"; return 'bg-red-100';
} }
return current ? "bg-green-100" : alternating_row_color return current ? 'bg-green-100' : alternating_row_color;
}); });
let analysis_visible = $state(false); let analysis_visible = $state(false);
function toggle_analysis_visibility() { function toggle_analysis_visibility() {
@@ -32,12 +39,20 @@
<tr class="{status_row_color} drop-shadow"> <tr class="{status_row_color} drop-shadow">
<td class="p-2">{entry.name}</td> <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.start_time)}</td>
<td class="p-2">{entry.last_message_time && date_formatter.format(entry.last_message_time) || "N/A"}</td> <td class="p-2"
>{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td
>
<td class="p-2">{entry.get_readable_qmdl_size()}</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">
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td> <div class="flex flex-row gap-2">
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td> <DownloadLink url={entry.get_pcap_url()} text="pcap" />
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></td> <DownloadLink url={entry.get_qmdl_url()} text="qmdl" />
<DownloadLink url={entry.get_zip_url()} text="zip" />
</div>
</td>
<td class="p-2"
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td
>
{#if current} {#if current}
<td class="p-2"></td> <td class="p-2"></td>
{:else} {:else}
@@ -45,12 +60,13 @@
<DeleteButton <DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`} prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()} url={entry.get_delete_url()}
name={entry.name}
/> />
</td> </td>
{/if} {/if}
</tr> </tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}"> <tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="9"> <td class="border-t border-dashed p-2" colspan="9">
<AnalysisView {entry} /> <AnalysisView {entry} {manager} {current} />
</td> </td>
</tr> </tr>
@@ -0,0 +1,48 @@
<script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte';
import { AnalysisStatus, AnalysisManager } from '$lib/analysisManager.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
let {
entry,
manager,
}: {
entry: ManifestEntry;
manager: AnalysisManager;
} = $props();
let url = $derived(entry.get_reanalyze_url());
let entry_name = $derived(entry.name);
let analysis_status = $derived(entry.analysis_status);
let is_processing = $derived(
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
);
async function handleReAnalyze() {
// Update the entry directly for immediate UI feedback
entry.analysis_status = AnalysisStatus.Queued;
entry.analysis_report = undefined;
manager.set_queued_status(entry_name);
}
</script>
<ApiRequestButton
{url}
label="Re-analyze"
loadingLabel="Analyzing..."
disabled={is_processing}
variant="blue"
onclick={handleReAnalyze}
ariaLabel="re-analyze"
errorMessage="Error re-analyzing recoding"
>
{#snippet icon()}
<svg style="width:20px;height:20px" viewBox="0 0 24 24">
<path
fill="white"
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z"
/>
</svg>
{/snippet}
</ApiRequestButton>
@@ -0,0 +1,60 @@
<script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte';
let {
server_is_recording,
}: {
server_is_recording: boolean;
} = $props();
</script>
<div>
{#if server_is_recording}
<ApiRequestButton
url="/api/stop-recording"
label="Stop"
variant="red"
errorMessage="Error stoppping recording"
>
{#snippet icon()}
<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>
{/snippet}
</ApiRequestButton>
{:else}
<ApiRequestButton
url="/api/start-recording"
label="Start"
variant="blue"
errorMessage="Error starting recording"
>
{#snippet icon()}
<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>
{/snippet}
</ApiRequestButton>
{/if}
</div>
@@ -1,34 +1,33 @@
<script lang="ts"> <script lang="ts">
import { type SystemStats } from "$lib/systemStats"; import { type SystemStats } from '$lib/systemStats';
let { stats }: { let {
stats,
}: {
stats: SystemStats; stats: SystemStats;
} = $props(); } = $props();
const table_cell_classes = "border p-1 lg:p-2"; const table_cell_classes = 'border p-1 lg:p-2';
</script> </script>
<div class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"> <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> <p class="text-xl mb-2">System Information</p>
<table class="table-auto border"> <table class="table-auto border">
<tbody> <tbody>
<tr class="border"> <tr class="border">
<th class={table_cell_classes}> <th class={table_cell_classes}> Rayhunter Version </th>
Rayhunter Version
</th>
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td> <td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
</tr> </tr>
<tr class="border"> <tr class="border">
<th class={table_cell_classes}> <th class={table_cell_classes}> Storage </th>
Storage
</th>
<td class={table_cell_classes}> <td class={table_cell_classes}>
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats.disk_stats.available_size} available) {stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
.disk_stats.available_size} available)
</td> </td>
</tr> </tr>
<tr class="border-b"> <tr class="border-b">
<th class={table_cell_classes}> <th class={table_cell_classes}> Memory (RAM) </th>
Memory (RAM)
</th>
<td class={table_cell_classes}> <td class={table_cell_classes}>
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used} Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td> </td>
@@ -1,5 +1,5 @@
import { get_report, type AnalysisReport } from "./analysis.svelte"; import { get_report, type AnalysisReport } from './analysis.svelte';
import { AnalysisStatus, type AnalysisManager } from "./analysisManager.svelte"; import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
interface JsonManifest { interface JsonManifest {
entries: JsonManifestEntry[]; entries: JsonManifestEntry[];
@@ -11,7 +11,6 @@ interface JsonManifestEntry {
start_time: string; start_time: string;
last_message_time: string; last_message_time: string;
qmdl_size_bytes: number; qmdl_size_bytes: number;
analysis_size_bytes: number;
} }
export class Manifest { export class Manifest {
@@ -19,7 +18,7 @@ export class Manifest {
public current_entry: ManifestEntry | undefined; public current_entry: ManifestEntry | undefined;
constructor(json: JsonManifest) { constructor(json: JsonManifest) {
for (let entry of json.entries) { for (const entry of json.entries) {
this.entries.push(new ManifestEntry(entry)); this.entries.push(new ManifestEntry(entry));
} }
if (json.current_entry !== null) { if (json.current_entry !== null) {
@@ -31,7 +30,7 @@ export class Manifest {
} }
async set_analysis_status(manager: AnalysisManager) { async set_analysis_status(manager: AnalysisManager) {
for (let entry of this.entries) { for (const entry of this.entries) {
entry.analysis_status = manager.status.get(entry.name); entry.analysis_status = manager.status.get(entry.name);
entry.analysis_report = manager.reports.get(entry.name); entry.analysis_report = manager.reports.get(entry.name);
} }
@@ -39,7 +38,7 @@ export class Manifest {
if (this.current_entry) { if (this.current_entry) {
try { try {
this.current_entry.analysis_report = await get_report(this.current_entry.name); this.current_entry.analysis_report = await get_report(this.current_entry.name);
} catch(err) { } catch (err) {
this.current_entry.analysis_report = `Err: failed to get analysis report: ${err}`; this.current_entry.analysis_report = `Err: failed to get analysis report: ${err}`;
} }
@@ -47,11 +46,11 @@ export class Manifest {
// analysis report is always available // analysis report is always available
this.current_entry.analysis_status = AnalysisStatus.Finished; this.current_entry.analysis_status = AnalysisStatus.Finished;
} }
} }
} }
export class ManifestEntry { export class ManifestEntry {
public name = $state(""); public name = $state('');
public start_time: Date; public start_time: Date;
public last_message_time: Date | undefined = $state(undefined); public last_message_time: Date | undefined = $state(undefined);
public qmdl_size_bytes = $state(0); public qmdl_size_bytes = $state(0);
@@ -62,7 +61,6 @@ export class ManifestEntry {
constructor(json: JsonManifestEntry) { constructor(json: JsonManifestEntry) {
this.name = json.name; this.name = json.name;
this.qmdl_size_bytes = json.qmdl_size_bytes; this.qmdl_size_bytes = json.qmdl_size_bytes;
this.analysis_size_bytes = json.analysis_size_bytes;
this.start_time = new Date(json.start_time); this.start_time = new Date(json.start_time);
if (json.last_message_time) { if (json.last_message_time) {
this.last_message_time = new Date(json.last_message_time); this.last_message_time = new Date(json.last_message_time);
@@ -70,16 +68,16 @@ export class ManifestEntry {
} }
get_readable_qmdl_size(): string { get_readable_qmdl_size(): string {
if (this.qmdl_size_bytes === 0) return "0 Bytes"; if (this.qmdl_size_bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
const dm = 2 || 2; const dm = 2;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(this.qmdl_size_bytes) / Math.log(k)); const i = Math.floor(Math.log(this.qmdl_size_bytes) / Math.log(k));
return `${Number.parseFloat((this.qmdl_size_bytes / k ** i).toFixed(dm))} ${sizes[i]}`; return `${Number.parseFloat((this.qmdl_size_bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
} }
get_num_warnings(): number | undefined { get_num_warnings(): number | undefined {
if (this.analysis_report === undefined || typeof(this.analysis_report) === 'string') { if (this.analysis_report === undefined || typeof this.analysis_report === 'string') {
return undefined; return undefined;
} }
return this.analysis_report.statistics.num_warnings; return this.analysis_report.statistics.num_warnings;
@@ -103,5 +101,9 @@ export class ManifestEntry {
get_delete_url(): string { get_delete_url(): string {
return `/api/delete-recording/${this.name}`; return `/api/delete-recording/${this.name}`;
} }
get_reanalyze_url(): string {
return `/api/analysis/${this.name}`;
}
} }
@@ -2,32 +2,32 @@ import { describe, it, expect } from 'vitest';
import { parse_ndjson } from './ndjson'; import { parse_ndjson } from './ndjson';
describe('parsing newline-deliminated json', () => { describe('parsing newline-deliminated json', () => {
it('parses normal JSON', () => { it('parses normal JSON', () => {
const json = JSON.stringify({ foo: 100 }); const json = JSON.stringify({ foo: 100 });
const result = parse_ndjson(json); const result = parse_ndjson(json);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0]).toEqual({ foo: 100 }); expect(result[0]).toEqual({ foo: 100 });
}); });
it('parses simple newline-deliminated json', () => { it('parses simple newline-deliminated json', () => {
const json_a = JSON.stringify({ a: 100 }); const json_a = JSON.stringify({ a: 100 });
const json_b = JSON.stringify({ b: 200 }); const json_b = JSON.stringify({ b: 200 });
const result = parse_ndjson(`${json_a}\n${json_b}`); const result = parse_ndjson(`${json_a}\n${json_b}`);
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0]).toEqual({ a: 100 }); expect(result[0]).toEqual({ a: 100 });
expect(result[1]).toEqual({ b: 200 }); expect(result[1]).toEqual({ b: 200 });
}) });
it('parses newline-deliminated json with escaped newlines within', () => { it('parses newline-deliminated json with escaped newlines within', () => {
const json_a = JSON.stringify({ a: 'this one has\n newlines and\nstuff' }); const json_a = JSON.stringify({ a: 'this one has\n newlines and\nstuff' });
const json_b = JSON.stringify({ b: 200 }); const json_b = JSON.stringify({ b: 200 });
const result = parse_ndjson(`${json_a}\n${json_b}`); const result = parse_ndjson(`${json_a}\n${json_b}`);
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0]).toEqual({ a: 'this one has\n newlines and\nstuff' }); expect(result[0]).toEqual({ a: 'this one has\n newlines and\nstuff' });
expect(result[1]).toEqual({ b: 200 }); expect(result[1]).toEqual({ b: 200 });
}) });
it('actually errors out on invalid ndjson', () => { it('actually errors out on invalid ndjson', () => {
expect(() => parse_ndjson("invalid\njson")).toThrow(); expect(() => parse_ndjson('invalid\njson')).toThrow();
}); });
}); });
+26
View File
@@ -0,0 +1,26 @@
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;
}
@@ -1,17 +1,22 @@
import { Manifest } from "./manifest.svelte"; import { add_error } from './action_errors.svelte';
import type { SystemStats } from "./systemStats"; import { Manifest } from './manifest.svelte';
import type { SystemStats } from './systemStats';
export interface AnalyzerConfig { export interface AnalyzerConfig {
imsi_requested: boolean; imsi_requested: boolean;
connection_redirect_2g_downgrade: boolean; connection_redirect_2g_downgrade: boolean;
lte_sib6_and_7_downgrade: boolean; lte_sib6_and_7_downgrade: boolean;
null_cipher: boolean; null_cipher: boolean;
nas_null_cipher: boolean;
incomplete_sib: boolean;
test_analyzer: boolean;
} }
export interface Config { export interface Config {
ui_level: number; ui_level: number;
colorblind_mode: boolean; colorblind_mode: boolean;
key_input_mode: number; key_input_mode: number;
ntfy_url: string;
analyzers: AnalyzerConfig; analyzers: AnalyzerConfig;
} }
@@ -27,6 +32,23 @@ export async function req(method: string, url: string): Promise<string> {
} }
} }
// A wrapper around req that reports errors to the UI
export async function user_action_req(
method: string,
url: string,
error_msg: string
): Promise<string | undefined> {
try {
return await req(method, url);
} catch (error) {
if (error instanceof Error) {
console.log('beeeo');
add_error(error, error_msg);
}
return undefined;
}
}
export async function get_manifest(): Promise<Manifest> { export async function get_manifest(): Promise<Manifest> {
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest')); const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
return new Manifest(manifest_json); return new Manifest(manifest_json);
@@ -46,9 +68,9 @@ export async function set_config(config: Config): Promise<void> {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(config) body: JSON.stringify(config),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.text(); const error = await response.text();
throw new Error(error); throw new Error(error);

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