271 Commits

Author SHA1 Message Date
Brad Warren
e17842c1b9 bump versions to 0.11.2 2026-05-28 09:27:46 -07:00
Markus Unterwaditzer
754faf10d9 Remove redundant helper functions 2026-05-27 18:51:32 +02:00
Markus Unterwaditzer
4bad8356ac Refactor record deletion to use FileKind 2026-05-27 18:51:32 +02:00
Markus Unterwaditzer
7aaa7e589e Fix similar bug in zip download, also use FileKind there 2026-05-27 18:51:32 +02:00
Markus Unterwaditzer
e3e84a0185 Fix WebDAV not uploading GPS files
When merging WebDAV and GPS features, we forgot to update the WebDAV
feature to also upload the GPS files.

WebDAV had hardcoded knowledge of which files exist and its own FileKind
enum. Move the FileKind enum into QMDL store so that webdav can be
agnostic over which files belong to a recording, so this is less likely
to happen again.

(This refactor was AI-assisted)
2026-05-27 18:51:32 +02:00
recanman
517a17db14 Software update notification (#1002) (#1054)
* add `auto_check_updates` config value

* add `auto_check_updates` to dist config

* add `Update` `NotificationType`

* implement update checker and worker

* add endpoint, add to documentation, add worker

* clone update_status_lock Arc

* fmt

* add more tests

* remove todo

* add to docs

* frontend update notice

* improve name in documentation

* add user-agent to update check request

* add update check request timeout

* openapi trait bound

* do not enable `auto_check_updates` by default

* remove redundant documentation

* surface fetch of update status error

* fail on version with pre-release for now, add additional test cases

* Update configuration.md

---------

Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
2026-05-24 22:59:18 +02:00
recanman
e86d30a0c6 frontend bugfix: Handle null input in ExpandableInput, make ntfy_url nullable (#1055)
* handle `null` input in `ExpandableInput`

* correct `ntfy_url` config type

* remove extra space
2026-05-22 22:10:04 +02:00
dependabot[bot]
e83ba9922d Bump @sveltejs/kit (#1049)
Bumps the security group with 1 update in the /installer-gui directory: [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit).


Updates `@sveltejs/kit` from 2.57.1 to 2.60.1
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.60.1/packages/kit)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 16:08:24 +02:00
dependabot[bot]
7e525c5c02 Bump @sveltejs/kit (#1050)
Bumps the security group with 1 update in the /daemon/web directory: [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit).


Updates `@sveltejs/kit` from 2.58.0 to 2.60.1
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.60.1/packages/kit)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 16:08:21 +02:00
recanman
22259dbbcc improve installation docs consistency (#1051)
Co-authored-by: Markus Unterwaditzer <markus-github@unterwaditzer.net>
2026-05-22 00:31:52 +02:00
Markus Unterwaditzer
4526203af8 Do not recompile installer if files are missing
Currently the installer is recompiled everytime a file is missing, even
if the file has been missing before and after the last compilation.

That is because rerun-if-changed on a nonexistent filepath constantly
busts the cache.
2026-05-19 10:35:35 -07:00
Markus Unterwaditzer
3e53aef145 Collapse ntfy settings if unused
Like in the webdav settings, have a checkbox that expands a few form
fields.
2026-05-19 19:34:24 +02:00
Ember
c2ba5a2a6c Add daemon startup smoke test and let it run on a PC in debug_mode
v0.11.0 shipped a daemon binary that built fine but didn't actually serve
a working frontend. CI was green. Nothing in the pipeline asserted that
the built binary comes up and serves something.

Add daemon/tests/smoke.rs as an integration test that spawns the built
binary against a tempdir-backed config with debug_mode = true, picks an
ephemeral port, and asserts:

- GET /index.html is 2xx and the decompressed body contains "Rayhunter"
- GET /api/qmdl-manifest is 2xx
- the daemon exits cleanly on SIGINT

Captures the daemon's stderr into a buffer so startup/shutdown failures
print actionable context instead of just "did not start listening".

Runs as part of the regular cargo test invocation, no new CI job.

For the smoke test (and #826) to work, the daemon needs to come up on a
PC without /dev/diag, a screen, or wpa_supplicant. The DIAG read thread,
display driver, and key input were already gated on debug_mode. Gate the
two remaining device-dependent workers the same way:

- run_battery_notification_worker (polls battery sysfs paths)
- wifi_station::run_wifi_client (talks to wpa_supplicant)

doc/installing-from-source.md gains a "Running the daemon on your PC"
section. doc/porting.md drops its duplicate debug_mode line and links to
the new section.

Closes #826.
2026-05-19 10:31:21 -07:00
Markus Unterwaditzer
5636cf4311 fix js formatting 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
0c90f8910a Use latest packet timestamp in GPS file, move writing into DiagTask to eliminate RwLocks, remove "sidecar" word from codebase 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
2ada840919 fix js lints 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
19df99d663 fix clippy 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
0259b101b5 compact UI a bit 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
9823fd3205 fix bad color in configform, move some docs into api docs 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
0783db6e01 reformat gps docs 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
0033b762d9 reformat frontend and refactor gps mode display 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
3aa3ce89c8 remove format! calls entirely 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
bd5dfb1a75 address most of wills review feedback, fix serialization and stringly error handling in DiagTask::start 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
a58bad09fc Remove unused import 2026-05-18 15:30:55 -07:00
Carlos Guerra
fc5593f00b rebasing with upsream (webdav and wifi client) 2026-05-18 15:30:55 -07:00
Carlos Guerra
049a842987 suggested fix for gps feature, sorting timestamps 2026-05-18 15:30:55 -07:00
Carlos Guerra
7bae34061d fixes requested, gps timestamp corrected, more error managing, more border conditions covered 2026-05-18 15:30:55 -07:00
Carlos Guerra
5a4a3034be requests addressed, better error handling, more logging, small text corrections 2026-05-18 15:30:55 -07:00
Carlos Guerra
ba78c7bd01 new merge conflict addressed, to_datetime unused code removed, some refactoring to appease tests 2026-05-18 15:30:55 -07:00
Carlos Guerra
fee082cde4 replacing numbered options in config with rust enum implementation, unique commit to make easier to debug or rollback 2026-05-18 15:30:55 -07:00
Carlos Guerra
0b91a6e5d3 PR chage requests, revision to GPS logging feature, code cleanup 2026-05-18 15:30:55 -07:00
Carlos Guerra
dbe102e366 including documentation changes 2026-05-18 15:30:55 -07:00
Carlos Guerra
43d9b278cd trimming gps.rs to go under 400 loc changed 2026-05-18 15:30:55 -07:00
Carlos Guerra
5451e23293 added documentation and polishing UI around GPS mode 2026-05-18 15:30:55 -07:00
Carlos Guerra
66f0c2a336 Kismet GPS option section per packet dcriped, refactoring to reduce loc 2026-05-18 15:30:55 -07:00
Carlos Guerra
adb316e2d7 GPS information included in PCAP files as comment and with Kismet proposed standard 2026-05-18 15:30:55 -07:00
Carlos Guerra
c107314194 GPS feature webapp side: GPS mode selector, fixed mode lat/lon, API endpoint. Merging with Wifi client and webdav features 2026-05-18 15:30:55 -07:00
Markus Unterwaditzer
ac33ebaf53 Remove stdout redirects
See #1016
2026-05-18 09:59:39 -07:00
Markus Unterwaditzer
047fbc216f Fix CI: MacOS runner update breaks rust-cache action
https://github.com/Swatinem/rust-cache/issues/341
2026-05-15 09:19:43 -07:00
dependabot[bot]
d43aa28294 Bump svelte in /daemon/web in the security group across 1 directory
Bumps the security group with 1 update in the /daemon/web directory: [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte).


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

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.55.7
  dependency-type: direct:development
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 00:40:16 +02:00
dependabot[bot]
af09802573 Bump svelte in /installer-gui in the security group across 1 directory
Bumps the security group with 1 update in the /installer-gui directory: [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte).


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

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.55.7
  dependency-type: direct:development
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 00:39:59 +02:00
Markus Unterwaditzer
866b269dbd Release 0.11.1 2026-05-12 12:32:37 -07:00
Markus Unterwaditzer
da4e723eff Revert "Compress the web frontend using brotli"
This reverts commit 00e4cb7a75.
2026-05-12 12:18:17 -07:00
Markus Unterwaditzer
a3e7d0ef43 Fix CI: installer-gui needs wpa-supplicant 2026-05-12 19:52:49 +02:00
Cooper Quintin
d941021853 bump version to 0.11.0 2026-05-12 09:37:02 -07:00
Markus Unterwaditzer
3fcd908d68 Add config UI for webdav uploader 2026-05-12 00:32:05 +02:00
Markus Unterwaditzer
e7ffebbb30 Add upload indicator to UI 2026-05-12 00:32:05 +02:00
Markus Unterwaditzer
6aa3491a6c Fix config template: host/path -> url 2026-05-12 00:32:05 +02:00
Cooper Quintin
f079bda4fe remove incorrect documentation 2026-05-11 14:48:34 -07:00
Markus Unterwaditzer
00e4cb7a75 Compress the web frontend using brotli
We can save 10 kB of binary size by compressing the frontend using
brotli on max settings instead of gzip. Any browser beyond 2017 will be
able to handle this, and since the Tailwind upgrade we already require
browsers from 2024. (see also #903)

Also we can stop using whatever gzlip cli is on the system, node has
some stuff builtin.

Source for the claim we require chrome 2023/firefox 2024 baseline right
now: https://tailwindcss.com/docs/compatibility

Compression comparison:

| codec | size (bytes) | vs gzip -9 | wire format | `Content-Encoding` |
|---|---:|---:|---|---|
| (uncompressed) | 171,833 | +210.6% | — | — |
| gzip -9 | 55,313 | — | gzip | `gzip` |
| pigz -9 | 55,436 | +0.2% | gzip | `gzip` |
| brotli q=4 | 55,085 | -0.4% | brotli | `br` |
| brotli q=6 | 51,518 | -6.9% | brotli | `br` |
| brotli q=9 | 51,243 | -7.4% | brotli | `br` |
| **pigz -11** (zopfli) | **53,340** | **-3.6%** (~2 KB) | **gzip** | `gzip` |
| **brotli q=11** | **47,712** | **-13.7%** (~7.4 KB) | **brotli** | `br` |
2026-05-11 14:04:26 -07:00
Markus Unterwaditzer
c4eca245b9 CI: Rebuild everything if Cargo.lock, dist/ or scripts/ changes
See https://github.com/EFForg/rayhunter/pull/1020
2026-05-08 17:22:22 -07:00
Ember
768d5c877e updated wifi-station dependency to use version published to crates.io (#1022)
* updated wifi-station dependency to use version published to crates.io

* drop unrelated windows-core downgrade from lockfile
2026-05-09 01:35:20 +02:00
Brad Warren
5152544cea fix conditions on build_wpa_supplicant 2026-05-08 20:28:21 +02:00
Brad Warren
ba34a58c5d update @tauri-apps packages 2026-05-08 20:28:21 +02:00
isacloud
51da77fde0 Update tplink-m7350.md: v1.0 supported (#1019) 2026-05-08 16:45:16 +02:00
dependabot[bot]
c571bdf1de Bump tauri from 2.10.3 to 2.11.1 in the security group across 1 directory (#1018)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 12:17:09 +02:00
Markus Unterwaditzer
54de3b3a38 Remove firewall feature (#996)
https://github.com/EFForg/rayhunter/pull/888 contained an entire set of
iptables rules to ensure that no traffic leaked.

We know that many of these devices are fairly insecure, that's how we
get rayhunter installed on most of them. But if an attacker already is
able to run commands on this device, they are most likely going to be
able to run iptables -F too. We should discuss real threatmodels before
adding stuff like this, because messing with iptables also just makes
accidental bricking more likely (see the moxee disk space fiasco)
2026-05-02 13:42:22 +02:00
Markus Unterwaditzer
3c1a164361 Make WiFi tools optional on the orbic installer too (#1009) 2026-04-27 19:05:40 +02:00
Markus Unterwaditzer
8346f18a5b Fix dependabot config
PR #998 was supposed to filter dependabot PRs to security updates only.
But applies-to was on the wrong level of nesting.

Also renamed the group from "dependency-type" to "security", right now
we get PRs like "Bump the dependency-type group in /installer-gui with 7
updates#1010"

Search for applies-to on this page:
https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference
-- it's on the same level as patterns.

This PR was fully AI-generated, description hand-written though.
2026-04-27 09:16:39 -07:00
John
40070b9339 Add support for WebDAV (#982)
* Add support for WebDAV

* Fix get_unuploaded_entries_with_age to use start_time

* Use better pattern when matching join! result

Co-authored-by: Markus Unterwaditzer <markus-github@unterwaditzer.net>

* Refactor Webdav with better string ownership

* Unformat Cargo.toml

* Add timeout config

* Use a single url config instead of host and path

* QMDL store returns single unuploaded entry at a time

* Reset Cargo.lock

---------

Co-authored-by: Markus Unterwaditzer <markus-github@unterwaditzer.net>
2026-04-26 20:56:23 +02:00
Jack Lund
38b1dd3de2 Move creation of the diag device into read thread
Moved the creation of the DiagDevice into the diag read task thread.
2026-04-26 20:55:59 +02:00
Brad Warren
258fdd9d21 update contributing.md for installer-gui 2026-04-24 12:14:34 -07:00
Markus Unterwaditzer
100d1de04c Remove some global reset styles in favor of explicit border colors, restore button cursors 2026-04-24 11:34:56 -07:00
Markus Unterwaditzer
177262bc1d upgrade tailwind
* moved to vite plugin for tailwind (it's recommended now)
* removed autoprefixer (v4 uses its own CSS thing now)
* postcss.config.js was used to wire up tailwind and autoprefixer, so
  it's gone
* tailwind.config.ts is gone, because v4 stores config in app.css using
  css variables
* fixed some renamed classes
2026-04-24 11:34:56 -07:00
Markus Unterwaditzer
fe69dd1abf downgrade tailwind for now, and fix build errors 2026-04-24 11:34:56 -07:00
dependabot[bot]
d56386a7e5 Bump the dependency-type group across 1 directory with 18 updates
Bumps the dependency-type group with 18 updates in the /daemon/web directory:

| Package | From | To |
| --- | --- | --- |
| [@sveltejs/adapter-auto](https://github.com/sveltejs/kit/tree/HEAD/packages/adapter-auto) | `3.3.1` | `7.0.1` |
| [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) | `2.53.4` | `2.58.0` |
| [@sveltejs/vite-plugin-svelte](https://github.com/sveltejs/vite-plugin-svelte/tree/HEAD/packages/vite-plugin-svelte) | `6.2.1` | `7.0.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.7.0` | `25.6.0` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.21` | `10.5.0` |
| [eslint](https://github.com/eslint/eslint) | `9.37.0` | `10.2.1` |
| [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) | `9.1.2` | `10.1.8` |
| [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte/tree/HEAD/packages/eslint-plugin-svelte) | `2.46.1` | `3.17.1` |
| [globals](https://github.com/sindresorhus/globals) | `15.15.0` | `17.5.0` |
| [prettier](https://github.com/prettier/prettier) | `3.6.2` | `3.8.3` |
| [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) | `3.4.0` | `3.5.1` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.53.7` | `5.55.5` |
| [svelte-check](https://github.com/sveltejs/language-tools) | `4.3.2` | `4.4.6` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.18` | `4.2.4` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.46.0` | `8.59.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.2` | `8.0.10` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `3.2.4` | `4.1.5` |



Updates `@sveltejs/adapter-auto` from 3.3.1 to 7.0.1
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-auto/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/adapter-auto@7.0.1/packages/adapter-auto)

Updates `@sveltejs/kit` from 2.53.4 to 2.58.0
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.58.0/packages/kit)

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

Updates `@types/node` from 24.7.0 to 25.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.4.21 to 10.5.0
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.21...10.5.0)

Updates `eslint` from 9.37.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.37.0...v10.2.1)

Updates `eslint-config-prettier` from 9.1.2 to 10.1.8
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/commits/v10.1.8)

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

Updates `globals` from 15.15.0 to 17.5.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v15.15.0...v17.5.0)

Updates `prettier` from 3.6.2 to 3.8.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.6.2...3.8.3)

Updates `prettier-plugin-svelte` from 3.4.0 to 3.5.1
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/v3.5.1/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/commits/v3.5.1)

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

Updates `svelte-check` from 4.3.2 to 4.4.6
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.3.2...svelte-check@4.4.6)

Updates `tailwindcss` from 3.4.18 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

Updates `typescript-eslint` from 8.46.0 to 8.59.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint)

Updates `vite` from 7.3.2 to 8.0.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

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

---
updated-dependencies:
- dependency-name: "@sveltejs/adapter-auto"
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.57.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: "@sveltejs/vite-plugin-svelte"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: autoprefixer
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: eslint
  dependency-version: 10.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: eslint-config-prettier
  dependency-version: 10.1.8
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: eslint-plugin-svelte
  dependency-version: 3.17.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: prettier-plugin-svelte
  dependency-version: 3.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: svelte
  dependency-version: 5.55.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: svelte-check
  dependency-version: 4.4.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: typescript-eslint
  dependency-version: 8.58.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependency-type
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 11:34:56 -07:00
dependabot[bot]
520d2e9a9e Bump postcss from 8.5.6 to 8.5.10 in /daemon/web
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 09:51:33 -07:00
Brad Warren
cf9384c841 set open-pull-requests-limit 2026-04-24 09:39:57 -07:00
Brad Warren
e61802fe1d group dependabot security updates 2026-04-24 09:39:57 -07:00
Markus Unterwaditzer
1c8a498d70 Make WiFi tools optional to build, refactor installer file handling
PR #888 introduced more files that the installer needs to bundle. Those
files in particular are annoying to deal with because now every
developer needs a working C crosscompiler to get the installer working.
This prompted me to do some other refactoring.

Refactor install-dev to not build the wifi tools if there is no
crosscompiler, and refactor the installer so that these files are loaded
at runtime when built in debug mode.

The build script only ever warns if files are missing, and depending on
debug/release mode, the get_file!() macro either panics at runtime or
fails compiling.

Now the installer can be built again without any files, clippy can be
run directly without any envvars, and the installer runs atleast for
devices that don't need those files. The orbic installer will panic at
runtime if the wifi tools haven't been built. Building the installer in
release mode still requires all files.

Another nicety of loading these files on runtime is that the installer
does not need to be recompiled when the daemon has been rebuilt. This
should make things like make.sh really obsolete, which bypass the
installer for speed.
2026-04-24 09:00:19 -07:00
Cooper Quintin
7daacb3b65 Revert "web: target older mobile browsers"
This reverts commit a8aae16fa1.
2026-04-23 09:16:28 -07:00
Cooper Quintin
4a9e9c507b ignore wifi tools builds 2026-04-23 09:16:28 -07:00
DeoJin
a8aae16fa1 web: target older mobile browsers 2026-04-22 12:03:17 -07:00
Ember
5fc6925d35 doc: document Wi-Fi auto-shutdown workaround for TMOHS1 and CT2MHS01 (#951)
Both devices ship with a Wi-Fi Standby timer that turns off the AP
after ~10 minutes with no clients, blocking remote access to Rayhunter
until a power cycle. Previous attempt (this PR's earlier commits) added
a Rayhunter config toggle to flip gWlanAutoShutdown in WCNSS_qcom_cfg.ini,
but the same setting is already exposed in each device's native admin UI
under Settings -> Sleep -> Wi-Fi Standby, so a code change is not needed.

Replace the config toggle with:
- Device-page walkthroughs with screenshots of each native UI setting
- FAQ entry for "can't reach the web UI after leaving it alone"
- Post-install hint from the tmobile/wingtech installers pointing at
  the docs and the setting location
2026-04-22 11:52:33 -07:00
Ember
3455adbf95 client mode added (#888)
* client mode added

* Prevent OTA daemons dmclient and upgrade from running and phoning home to Verizon

* Fix workflow

* WIFI changes to support moxee. May need to rebase as delivering refactoring under other PR.

* code changes for rust based wifi client mode docs next

* Doc changes & security fixes

* Added watchdog and recover if crash occurs for wifi.

* Remove changes which were from device UI work (seperate feature which snuck into this branch)

* Add missing wifi and firewall module declarations

* cleaning up the code a bit

* Gate wpa_suplicant in installer and workflow to avoid building binary every push

* fix to check diskspace

* Improved support for subnet colisions, and attempts to rejoin network.

* Add WiFi client support and S01iptables to T-Mobile and Wingtech installers

Both installers now deploy wpa_supplicant, wpa_cli, udhcpc-hook.sh, and
the S01iptables boot-time firewall script. Config generation uses the
shared install_config/install_wifi_creds helpers instead of manual string
replacement.

* Revert "Add WiFi client support and S01iptables to T-Mobile and Wingtech installers"

This reverts commit 944b369c4f.

* Fix build: ignore unused wifi_ssid/wifi_password fields in T-Mobile and Wingtech installers

* Moved to a wifi crate

* Add host route and arp_filter to prevent subnet collisions

* add wakelock so kernel doesn't shut down wifi on battery when wifi is enabled

* Move wifi to external wifi-station crate, remove wifi from installer, extract OTA blocking

* fixed outdated info, moved udhcpc hook to wifi-station crate.

* Update to new version of wifi-station

* Address PR review feedback: replace Docker wpa build, add iw, remove OTA, revert unrelated changes

- Replace Docker-based wpa_supplicant build with shell script (scripts/build-wpa-supplicant.sh)
- Add iw cross-compilation and deployment to Orbic installer
- Skip wifi tool install if binary already exists on device
- Remove OTA daemon blocker (extracted for separate PR)
- Revert unrelated UZ801 and T-Mobile installer changes
- Remove connection.rs test scaffolding
- Rewrite S01iptables init script to read config.toml directly
- Pin url crate to 2.5.4 to fix MSRV

* Fix build script: use bash for parameter substitution

The ${VAR//pattern/replacement} syntax is a bash extension that
doesn't work in dash (Ubuntu's /bin/sh).

* Fix iw build: export PKG_CONFIG_LIBDIR as env var

Passing PKG_CONFIG_LIBDIR as a make variable doesn't export it to
$(shell pkg-config ...) calls. Set it as an environment variable
so pkg-config finds the cross-compiled libnl.

* Point wifi-station to GitHub rev 97c579a

* add comment

* Update daemon/src/config.rs

Add decorators

Co-authored-by: Andrej Walilko <walilkoa@gmail.com>

* Update daemon/src/server.rs

add utopia doc support

Co-authored-by: Andrej Walilko <walilkoa@gmail.com>

* Update daemon/src/server.rs

add utopia doc support

Co-authored-by: Andrej Walilko <walilkoa@gmail.com>

* Update to wifi-station with utoipa doc strings

* add utoipa to wifi-station

* added WPA3 support

* fix firewall port detection, update wifi-station to c267d37

fix ntfy port_or_known_default, comment out ntfy_url in config
template, update wifi-station with resolv.conf bind mount
fallback, udhcpc_bin config, and module path fix for UZ801

* show wifi UI for tmobile and wingtech, add udhcpc_bin config

both devices have wifi hardware and backend support. wingtech
verified on hardware (QCA6174 via PCIe). uz801 excluded for now
due to driver scan limitations with hostapd active.

* install wifi tools from orbic-usb installer, fix DNS default to Quad9, bump wifi-station rev

* fix Modal scroll listener leak, correct file transfer timeout math, document firewall fail-open, clarify UZ801 wifi status

* build-dev.sh: build wifi tools so install-dev works for orbic-family devices

* update Cargo.lock for wifi-station e8ec5b4

* fix setup_timeout_server crypto provider install, apply rustfmt

* Update installer/src/connection.rs

Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>

* Update installer/src/orbic.rs

Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>

* apply rustfmt to AdbConnection::run_command

---------

Co-authored-by: Andrej Walilko <walilkoa@gmail.com>
Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>
2026-04-22 10:02:48 -07:00
dependabot[bot]
416f03159a Bump the dependency-type group in /installer-gui with 3 updates
Bumps the dependency-type group in /installer-gui with 3 updates: [eslint](https://github.com/eslint/eslint), [typescript](https://github.com/microsoft/TypeScript) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

Updates `vite` from 8.0.8 to 8.0.9
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.9/packages/vite)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-type
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-type
- dependency-name: vite
  dependency-version: 8.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-type
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 09:43:15 -07:00
Brad Warren
bf08066c58 run cargo update tauri --precise 2.10.3 2026-04-16 12:59:04 -07:00
dependabot[bot]
d5f01847fc Bump the dependency-type group in /installer-gui with 18 updates
Dependabot couldn't find the original pull request head commit, ecdd7baeae.
2026-04-16 12:59:04 -07:00
Markus Unterwaditzer
ca30a146b2 Fix clippy for Rust 1.95 2026-04-16 12:31:08 -07:00
Markus Unterwaditzer
a1a29b5ec8 Upgrade rustls-webpki to fix CVE
We have two versions of rustls-webpki in our deptree:

- One used in the ring backend, which we're upgrading here
- One used in the rustcrypto backend, which we can't upgrade and
  therefore have to ignore the CVE anyway.

The ring backend is the one we actually use in release builds.
rustcrypto is only used during development builds to make compilation
simpler.
2026-04-16 10:13:53 -07:00
Markus Unterwaditzer
dc1d193b8e Move from ring to aws-lc-rs
There is some recent progress on quantum computers being discussed on
HackerNews and lobste.rs, and as a result of that timelines for when PQ
crypto would become essentially mandatory are being adjusted. Example:
https://words.filippo.io/crqc-timeline/

We pretty much have only one place in this entire codebase where any
sort of crypto happens, which is HTTPS for notifications support.

It seems that ring has essentially no plans to support PQ crypto for our
purposes. rustls/rustls#2801 briansmith/ring#1685

There's not really a reason to stick with ring, other than that it is a
prod-ready backend. But so is aws-lc-rs, and it seems to be the way
forward if you want PQ crypto today. Maybe that will change again in a
few years.

**The local dev workflow stays the same**, `cargo
build-daemon-firmware-devel` still uses rustcrypto which doesn't require
CC and doesn't have PQ crypto at all. We have no contribution docs for
how to build anything else anyway.

**Implementation:**

This opens a can of worms in building rayhunter-daemon in CI: We're
currently building ring using GCC cross-compilation toolchain from
Debian, which will build ring against **glibc**. Then we take that
library and try to link it against MUSL libc. The reason this works is
because ring's libc usage is very minimal, and the required symbols end
up being just the same as what MUSL libc exposes. The same can't be said
for aws-lc:

```
error: linking with `rust-lld` failed: exit status: 1
    = note: rust-lld: error: undefined symbol: __nanosleep64
            >>> referenced by urandom.c
            >>>               urandom.c.o:(do_backoff) in archive
```

So we fix that and link everything we build against MUSL libc (something
we should've done from the start anyway). The problem is that Debian
doesn't ship a MUSL cross-compilation toolchain, and the toolchain
available on https://musl.cc should not be downloaded directly in CI.
Which leaves us with a docker container from messense... That docker
container seems to be extremely popular for cross compilation across
GitHub projects, at least. I couldn't get other options to run reliably
(cross), or they were a too extreme change for my taste (using zig cc)
2026-04-16 10:12:24 -07:00
dependabot[bot]
11608427bb Bump the dependency-type group in /tools with 4 updates
Bumps the dependency-type group in /tools with 4 updates: [asn1tools](https://github.com/eerimoq/asn1tools), [bitstruct](https://github.com/eerimoq/bitstruct), [pycrate](https://github.com/pycrate-org/pycrate) and [pyparsing](https://github.com/pyparsing/pyparsing).


Updates `asn1tools` from 0.166.0 to 0.167.0
- [Release notes](https://github.com/eerimoq/asn1tools/releases)
- [Commits](https://github.com/eerimoq/asn1tools/compare/0.166.0...0.167.0)

Updates `bitstruct` from 8.19.0 to 8.22.1
- [Commits](https://github.com/eerimoq/bitstruct/compare/8.19.0...8.22.1)

Updates `pycrate` from 0.7.8 to 0.7.11
- [Release notes](https://github.com/pycrate-org/pycrate/releases)
- [Commits](https://github.com/pycrate-org/pycrate/compare/0.7.8...0.7.11)

Updates `pyparsing` from 3.1.2 to 3.3.2
- [Release notes](https://github.com/pyparsing/pyparsing/releases)
- [Changelog](https://github.com/pyparsing/pyparsing/blob/master/CHANGES)
- [Commits](https://github.com/pyparsing/pyparsing/compare/pyparsing_3.1.2...3.3.2)

---
updated-dependencies:
- dependency-name: asn1tools
  dependency-version: 0.167.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: bitstruct
  dependency-version: 8.22.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-type
- dependency-name: pycrate
  dependency-version: 0.7.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-type
- dependency-name: pyparsing
  dependency-version: 3.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-type
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 11:16:12 -07:00
Markus Unterwaditzer
1bab75830b Use grouped dependabot updates
When there is a CVE in some JS package, it seems to coincide with an
avalanche of security releases of random other packages.

Dependabot can actually create bulk PRs, let's try those.
2026-04-15 20:12:37 +02:00
Markus Unterwaditzer
853ad3763c apply suggestions from code review (github UI is broken) 2026-04-15 10:58:17 -07:00
Markus Unterwaditzer
059a5028d1 Update doc/porting.md
Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>
2026-04-15 10:58:17 -07:00
Ember
c1270cc3e9 Suggested edits 2026-04-15 10:58:17 -07:00
Markus Unterwaditzer
e935904204 Add docs for porting to new devices 2026-04-15 10:58:17 -07:00
Cooper Quintin
87975181c1 Update CONTRIBUTING.md
Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
2026-04-15 10:57:34 -07:00
Cooper Quintin
c2d8d55296 limit PR to 400 LOC 2026-04-15 10:57:34 -07:00
Jack Lund
188bf812b4 Add notification timeout
Adds a default timeout of 10 seconds for sending notifications so they
don't hang indefinitely. This can happen if the server connected to is
not responding or the case where there's a SIM card in the device, but
it's unactivated so that DNS works but the connection doesn't
2026-04-13 18:17:42 +02:00
dependabot[bot]
ccd96de5cc Bump vite from 6.4.1 to 6.4.2 in /installer-gui
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 15:41:18 -07:00
dependabot[bot]
a3072a2632 Bump vite from 7.1.11 to 7.3.2 in /daemon/web
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.11 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 00:14:54 +02:00
Markus Unterwaditzer
0580a8af33 Revise moxee warning
Ref #865

I think we should still make it clear the Orbic is a better device, but
there's not really any danger anymore.
2026-03-30 15:12:12 -07:00
Markus Unterwaditzer
a80a985b40 Fix wrong docs on key input
We explain in the docs what this key input mode actually does, but it is
named poorly.

ref #952.
2026-03-30 15:11:45 -07:00
Markus Unterwaditzer
228596ef30 Bump tower to 0.5.3
There are no important changes in the new tower version. The only reason
i'm bumping it is because it exposes our mis-configuration of tokio: We
depend on API surface enabled by `io-std`, but don't enable that
feature. Tower 0.5.2 pulls in that feature though, and our code works
anyway. Tower 0.5.3 no longer requires this feature and our code stops
compiling.
2026-03-30 15:10:59 -07:00
Markus Unterwaditzer
a7409b281b Move config form to modal
It's pretty annoying to have to scroll past all the old recordings to
be able to set the config. Since logs we already have figured out how to
create modals, so let's reuse that code.
2026-03-30 15:10:12 -07:00
dependabot[bot]
6a57bdebc4 Bump picomatch from 2.3.1 to 2.3.2 in /installer-gui
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 14:14:11 +01:00
dependabot[bot]
7cb405c465 Bump picomatch from 2.3.1 to 2.3.2 in /daemon/web
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 14:14:09 +01:00
Ember
bada3846dc Get rid of the 'verify' which is flakey 2026-03-26 11:44:23 +01:00
Ember
f0849340cf Improve Moxee/orbic Wingtech/tmobile installers for network stability 2026-03-26 11:44:23 +01:00
Terence
512cf784a7 provide additional info to help users set up for frontend dev environment (#917) 2026-03-25 20:09:24 +01:00
dependabot[bot]
100960bbe1 Bump flatted from 3.3.3 to 3.4.2 in /installer-gui
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 14:03:42 +01:00
dependabot[bot]
9d275e1793 Bump flatted from 3.3.3 to 3.4.2 in /daemon/web
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 13:08:45 +01:00
dependabot[bot]
fd190c4b75 Bump quinn-proto from 0.11.12 to 0.11.14
Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.12 to 0.11.14.
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.12...quinn-proto-0.11.14)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 15:55:12 +01:00
dependabot[bot]
ff838c41fa Bump devalue from 5.6.3 to 5.6.4 in /daemon/web
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.6.3 to 5.6.4.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.6.3...v5.6.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-12 11:25:27 -07:00
dependabot[bot]
a031e8ccfc Bump devalue from 5.6.3 to 5.6.4 in /installer-gui
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.6.3 to 5.6.4.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.6.3...v5.6.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-12 11:13:04 -07:00
Brad Warren
a6f5faa80e wrap report in $derived 2026-03-04 15:44:35 -08:00
dependabot[bot]
43f1dfce64 Bump svelte from 5.39.10 to 5.53.6 in /daemon/web
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.39.10 to 5.53.6.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.53.6/packages/svelte)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 15:44:35 -08:00
dependabot[bot]
54adaf913d Bump @sveltejs/kit from 2.49.5 to 2.53.0 in /daemon/web
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.49.5 to 2.53.0.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.53.0/packages/kit)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 15:29:21 -08:00
dependabot[bot]
ab418ecc84 Bump devalue from 5.6.2 to 5.6.3 in /daemon/web
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.6.2 to 5.6.3.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.6.2...v5.6.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 15:25:20 -08:00
dependabot[bot]
2fd028dc78 Bump rollup from 4.52.4 to 4.59.0 in /daemon/web
Bumps [rollup](https://github.com/rollup/rollup) from 4.52.4 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.52.4...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 15:21:42 -08:00
dependabot[bot]
d413840c08 Bump minimatch in /daemon/web
Bumps  and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together.

Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `minimatch` from 9.0.5 to 9.0.9
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: minimatch
  dependency-version: 9.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 15:20:51 -08:00
Markus Unterwaditzer
2f1b583e00 orbic-network: Disable connection pooling
Every once in a while, I get the error posted in #901:

    Caused by:
        0: Failed to send login request
        1: error sending request
        2: client error (SendRequest)
        3: connection closed before message completed

(either this or "failed to start telnet" -- in either case there's a
request happening before it, and it's always "connection closed before
message completed")

Disabling connection pooling seems to reduce the amount of flakiness.

Here is what I used to test this fix:

    while echo | cargo run -p installer util orbic-shell --admin-password 96df5476 ; do true; done

Usually it would stop <100 iterations, now it can do 800+ iterations.
2026-03-03 10:59:42 -08:00
dependabot[bot]
adeeb75166 Bump minimatch in /installer-gui
Bumps  and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together.

Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `minimatch` from 9.0.5 to 9.0.9
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: minimatch
  dependency-version: 9.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 12:07:03 -08:00
dependabot[bot]
4ca23f37c3 Bump rollup from 4.52.5 to 4.59.0 in /installer-gui
Bumps [rollup](https://github.com/rollup/rollup) from 4.52.5 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.52.5...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 12:06:24 -08:00
dependabot[bot]
15b80ecdd5 Bump svelte from 5.53.0 to 5.53.6 in /installer-gui
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.53.0 to 5.53.6.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.53.6/packages/svelte)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 12:03:39 -08:00
Lawrence Kwan
c5de9b045a fix: fix typo in connection error message
Change 'do loss' to 'due to loss' in the connection error message.

Fixes #864
2026-02-26 14:19:09 -08:00
oopsbagel
37283deddb CONTRIBUTING: add genai policy with links 2026-02-24 14:45:28 -08:00
oopsbagel
49d7bbca34 pr templates: add checkboxes for our genai policy
This commit introduces checkboxes where users can certify that either
they did not use generative AI to create their pull request, or that
they did but they understand the code and wrote all comments and
descriptions themselves, per our recently publicised open source policy.
2026-02-24 14:45:28 -08:00
Will Greenberg
a4c32f49ae also bump installer-guis version 2026-02-24 14:25:45 -08:00
Will Greenberg
ec30a9557c bump to v10.2 2026-02-24 14:19:08 -08:00
Markus Unterwaditzer
a7d38730f5 fix botched rebase 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer
d9facdf6cb add one missing single quote 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer
90f49f73c8 slightly better escaping for user input 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer
8aa45f4b53 better errorhandling 2026-02-24 13:42:31 -08:00
Markus Unterwaditzer
d8da6118da Update installer/src/lib.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2026-02-24 13:42:31 -08:00
Markus Unterwaditzer
3e38f500a9 Install to /cache/rayhunter-data for tplink, add --data-dir parameter
This fixes several space-related issues at once.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* utoipa openapi docs generator

---------

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

* Apply suggestion from @untitaker

---------

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

Reverts #804
Reverts #830

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fix #794
2026-01-26 10:33:56 -08:00
dependabot[bot]
3a807f48b2 Bump @sveltejs/kit from 2.46.2 to 2.49.5 in /daemon/web
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.46.2 to 2.49.5.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.49.5/packages/kit)

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

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

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

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

Found using differential fuzzing.

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

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

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

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

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

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

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

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

* Added language referencing the upgrade instructions in installation landing page

* Update doc/installation.md

---------

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

the chosen options were:

Project name · installer-gui
Identifier · com.rayhunter-installer.app
Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, deno, bun)
Choose your package manager · npm
Choose your UI template · Svelte - (https://svelte.dev/)
Choose your UI flavor · TypeScript
2025-11-05 10:53:41 -08:00
180 changed files with 16519 additions and 6798 deletions

15
.cargo/audit.toml Normal file
View File

@@ -0,0 +1,15 @@
[advisories]
ignore = [
# RSA Marvin Attack in `rsa`, dragged in through rustcrypto (dev builds)
# and adb_client (USB signing only, unrelated to marvin attack which
# targets decryption).
"RUSTSEC-2023-0071",
# paste crate being unmaintained is not important. it's not dealing with
# user-input. we could get rid of this warning by disabling the image
# dependency in adb-client.
"RUSTSEC-2024-0436",
# rustls-webpki 0.102.8 CRL Distribution Point flaw (via rustls-rustcrypto).
# Only affects dev builds, production firmware uses ring-tls.
# TODO: Remove once rustls-rustcrypto releases a version newer than 0.0.2-alpha.
"RUSTSEC-2026-0049",
]

View File

@@ -1,10 +1,16 @@
[alias]
# Build the daemon with "firmware" profile and "ring" TLS backend.
# Requires a cross-compiler (see github actions workflows) and is very slow to build.
build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features ring-tls"
# Build the daemon with "firmware" profile and post-quantum TLS backend.
# Needs an arm-linux-musleabihf cross-compiler in PATH, e.g. a toolchain
# from https://musl.cc, or run inside messense/rust-musl-cross:armv7-musleabihf
# (which is what CI does, see .github/workflows/main.yml).
build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features pq-tls"
# Build the daemon with "firmware-devel" profile and "rustcrypto" backend.
# Works with just the Rust toolchain, and is medium-slow to build. Binaries are slightly larger.
build-daemon-firmware-devel = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware-devel"
# Build rootshell for firmware
build-rootshell-firmware = "build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware"
# Build rootshell for development
build-rootshell-firmware-devel = "build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware-devel"
[target.aarch64-apple-darwin]
linker = "rust-lld"

1
.gitattributes vendored
View File

@@ -7,3 +7,4 @@
dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf
scripts/*.sh eol=lf

View File

@@ -1,5 +1,7 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Frequently Asked Questions
url: https://efforg.github.io/rayhunter/faq.html
- name: Questions and community
url: https://efforg.github.io/rayhunter/support-feedback-community.html
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help on GitHub discussions or Mattermost

53
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
# open-pull-requests-limit is used to disable automated version updates
# security updates are unaffected. see
# * https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/configuring-dependabot-version-updates#disabling-dependabot-version-updates
# * https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#open-pull-requests-limit-
version: 2
updates:
# Rust dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
groups:
security:
applies-to: "security-updates"
patterns:
- "*"
# Python dependencies
- package-ecosystem: "pip"
directory: "/tools"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
groups:
security:
applies-to: "security-updates"
patterns:
- "*"
# daemon/web Node.js dependencies
- package-ecosystem: "npm"
directory: "/daemon/web"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
groups:
security:
applies-to: "security-updates"
patterns:
- "*"
# installer-gui Node.js dependencies
- package-ecosystem: "npm"
directory: "/installer-gui"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
groups:
security:
applies-to: "security-updates"
patterns:
- "*"

View File

@@ -1,7 +1,13 @@
## Pull Request Checklist
- [ ] The Rayhunter team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
- [ ] The Rayhunter team has recently expressed interest in reviewing a PR for this.
- If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
- [ ] Added or updated any documentation as needed to support the changes in this PR.
- [ ] Code has been linted and run through `cargo fmt`
- [ ] If any new functionality has been added, unit tests were also added
- [ ] [./CONTRIBUTING.md](../CONTRIBUTING.md) has been read
- [ ] Code has been linted and run through `cargo fmt`.
- [ ] If any new functionality has been added, unit tests were also added.
- [ ] [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md) has been read.
- [ ] Your pull request is fewer than ~400 lines of code.
You must check one of:
- [ ] No generative AI (including LLMs) tools were used to create this PR.
- [ ] Generative AI was used to create this PR. I certify that I have read and understand the code, and *that all comments and descriptions were authored by myself* and are not the product of generative AI.

View File

@@ -11,6 +11,9 @@ env:
CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
FILE_WPA_SUPPLICANT: ../../wpa-supplicant/wpa_supplicant
FILE_WPA_CLI: ../../wpa-supplicant/wpa_cli
FILE_IW: ../../wpa-supplicant/iw
RUSTFLAGS: "-Dwarnings"
jobs:
@@ -20,66 +23,92 @@ jobs:
permissions:
contents: read
outputs:
code_changed: ${{ steps.files_changed.outputs.code_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 }}
installer_changed: ${{ steps.files_changed.outputs.installer_count }}
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
code_changed: ${{ steps.files_changed.outputs.code_count != '0' }}
daemon_changed: ${{ steps.files_changed.outputs.daemon_count != '0' }}
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }}
installer_build: ${{ steps.files_changed.outputs.installer_build != '0' }}
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: detect file changes
id: files_changed
run: |
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, 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/ -e ^.cargo
# We rebuild everything if any of these conditions hold:
# * We are on main
# * Changes are made to github workflows
# * A cargo-workspace file changed (lockfile or .cargo), as that could affect any crate anywhere
# * Something from the script or dist folder changed (could be gated to installer, but some scripts like build_wpa_supplicant are part of the build process)
# * #build-all was used by the user to explicitly ask for this
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo -e '^Cargo\.lock$' -e '^Cargo\.toml$' -e ^dist/ -e ^scripts/ || git log -1 --format='%s %b' | grep -qF '#build-all'
then
echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT"
echo daemon_count=forced >> "$GITHUB_OUTPUT"
echo web_count=forced >> "$GITHUB_OUTPUT"
echo docs_count=forced >> "$GITHUB_OUTPUT"
echo installer_build=forced >> "$GITHUB_OUTPUT"
echo installer_count=forced >> "$GITHUB_OUTPUT"
echo installer_gui_count=forced >> "$GITHUB_OUTPUT"
echo rootshell_count=forced >> "$GITHUB_OUTPUT"
else
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^installer -e ^check -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "daemon_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^lib -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "web_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon/web | wc -l)" >> "$GITHUB_OUTPUT"
echo "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
echo "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"
installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer/ | wc -l)
installer_gui_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer-gui | wc -l)
if [ $installer_count != "0" ] || [ $installer_gui_count != "0" ]; then
echo "installer_build=1" >> "$GITHUB_OUTPUT"
else
echo "installer_build=0" >> "$GITHUB_OUTPUT"
fi
echo "installer_count=$installer_count" >> "$GITHUB_OUTPUT"
echo "installer_gui_count=$installer_gui_count" >> "$GITHUB_OUTPUT"
fi
mdbook_test:
name: Test mdBook Documentation builds
needs: files_changed
if: needs.files_changed.outputs.docs_changed != '0'
if: needs.files_changed.outputs.docs_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_publish:
name: Publish mdBook to Github Pages
mdbook_build:
name: Build mdBook for Github Pages
needs: mdbook_test
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
@@ -87,23 +116,22 @@ jobs:
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-artifact@v4
with:
name: book
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4
check_and_test:
needs: files_changed
if: needs.files_changed.outputs.code_changed != '0'
if: needs.files_changed.outputs.code_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
@@ -116,17 +144,48 @@ jobs:
npm install
npm run build
popd
NO_FIRMWARE_BIN=true cargo check --verbose
cargo check --verbose
- name: Run tests
run: |
NO_FIRMWARE_BIN=true cargo test --verbose
cargo test --verbose
- name: Run clippy
run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose
cargo clippy --verbose
test_web_frontend:
installer_gui_check:
# we test the GUI installer separately to:
# 1) mimic the default behavior of cargo commands for rayhunter devs where
# installer-gui isn't one of the default workspace packages
# 2) avoid slowing down development on changes unrelated to the GUI installer
needs: files_changed
if: needs.files_changed.outputs.web_changed != '0'
if: needs.files_changed.outputs.installer_gui_changed == 'true'
# we run this on macos simply because no additional OS packages need to be
# installed
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
cache-bin: 'false'
# we don't need to run cargo fmt here because both cargo fmt and cargo
# fmt --all runs on all workspace packages so this is handled by
# check_and_test above
- name: Check
run: cargo check --package installer-gui --verbose
- name: Run clippy
run: cargo clippy --package installer-gui --verbose
test_daemon_frontend:
needs: files_changed
if: needs.files_changed.outputs.web_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -135,33 +194,54 @@ jobs:
working-directory: daemon/web
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm install
- run: npm run lint
- run: npm run check
- run: npm run test
test_installer_frontend:
needs: files_changed
if: needs.files_changed.outputs.installer_gui_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: installer-gui
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm install
- run: npm run lint
- run: npm run check
windows_installer_check_and_test:
needs: files_changed
if: needs.files_changed.outputs.installer_changed != '0'
if: needs.files_changed.outputs.installer_changed == 'true'
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: cargo check
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo check --verbose
cargo check --verbose
- name: cargo test
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
cargo test --verbose --no-default-features
build_rayhunter_check:
if: needs.files_changed.outputs.daemon_changed != '0'
if: needs.files_changed.outputs.daemon_changed == 'true'
needs:
- check_and_test
- files_changed
@@ -192,10 +272,15 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
with:
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
cache-bin: 'false'
- name: Build rayhunter-check
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
@@ -205,7 +290,7 @@ jobs:
if-no-files-found: error
build_rootshell:
if: needs.files_changed.outputs.rootshell_changed != '0' || needs.files_changed.outputs.installer_changed != '0'
if: needs.files_changed.outputs.rootshell_needed == 'true'
needs:
- check_and_test
- files_changed
@@ -214,6 +299,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
@@ -226,11 +313,32 @@ jobs:
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
if-no-files-found: error
build_wpa_supplicant:
if: needs.files_changed.outputs.installer_build == 'true'
needs:
- files_changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install cross-compiler
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
- name: Build wpa_supplicant (armv7)
run: CC=arm-linux-gnueabihf-gcc STRIP=arm-linux-gnueabihf-strip HOST=arm-linux-gnueabihf scripts/build-wpa-supplicant.sh
- uses: actions/upload-artifact@v4
with:
name: wpa-supplicant
path: |
tools/build-wpa-supplicant/out/wpa_supplicant
tools/build-wpa-supplicant/out/wpa_cli
tools/build-wpa-supplicant/out/iw
if-no-files-found: error
build_rayhunter:
# build_rust_installer needs this step. so when installer_changed, we need
# to build this step too. if we skip this step because only the installer
# changed, the build_rust_installer step will be skipped too.
if: needs.files_changed.outputs.daemon_changed != '0' || needs.files_changed.outputs.installer_changed != '0'
if: needs.files_changed.outputs.daemon_needed == 'true'
needs:
- check_and_test
- files_changed
@@ -240,27 +348,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Install ARM cross-compilation toolchain
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
- name: Build rayhunter-daemon (armv7)
persist-credentials: false
- name: Build frontend
run: |
pushd daemon/web
npm install
npm run build
popd
# Run with -p so that cargo will select the minimum feature set for this package.
#
# Otherwise, it will consider the union of all requested features
# from all packages in the workspace. For example, if installer
# requires tokio with "full" feature, it will be included no matter
# what the feature selection in rayhunter-daemon is.
#
# https://github.com/rust-lang/cargo/issues/4463
CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
- name: Build rayhunter-daemon (armv7)
# Cross-compile inside messense/rust-musl-cross, which bundles an
# arm-linux-musleabihf cross gcc that aws-lc-sys needs.
run: |
mkdir -p "$HOME/.cargo-musl-cross"
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/work \
-v "$HOME/.cargo-musl-cross":/cargo-home \
-e CARGO_HOME=/cargo-home \
-w /work \
messense/rust-musl-cross:armv7-musleabihf \
cargo build-daemon-firmware
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon
@@ -268,13 +376,14 @@ jobs:
if-no-files-found: error
build_rust_installer:
if: needs.files_changed.outputs.installer_changed != '0'
if: needs.files_changed.outputs.installer_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- build_wpa_supplicant
- files_changed
- windows_installer_check_and_test
strategy:
@@ -301,11 +410,16 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
with:
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
cache-bin: 'false'
- run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
@@ -313,6 +427,152 @@ jobs:
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_installer_gui_linux:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- build_wpa_supplicant
- files_changed
- installer_gui_check
- test_installer_frontend
strategy:
matrix:
platform:
# we want to use the oldest supported version of ubuntu here to
# maximize compatibility with older versions of glibc
- name: linux-x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: linux-aarch64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- name: Install tauri dependencies
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev xdg-utils
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-appimage
path: target/${{ matrix.platform.target }}/release/bundle/appimage/*.AppImage
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-deb
path: target/${{ matrix.platform.target }}/release/bundle/deb/*.deb
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-rpm
path: target/${{ matrix.platform.target }}/release/bundle/rpm/*.rpm
if-no-files-found: error
build_installer_gui_macos:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- build_wpa_supplicant
- files_changed
- installer_gui_check
- test_installer_frontend
strategy:
matrix:
platform:
- name: macos-arm
target: aarch64-apple-darwin
- name: macos-intel
target: x86_64-apple-darwin
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
with:
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
# bin caching breaks cargo resolution on macOS 15 runner images
cache-bin: 'false'
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ matrix.platform.target }}
cd ..
mv "target/${{ matrix.platform.target }}/release/bundle/macos/"*.app .
zip -r "rayhunter-installer-${{ matrix.platform.name }}.app.zip" ./*.app
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-app
path: ./*.app.zip
if-no-files-found: error
build_installer_gui_windows:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- build_wpa_supplicant
- files_changed
- installer_gui_check
- test_installer_frontend
env:
TARGET: x86_64-pc-windows-msvc
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ env.TARGET }}
- uses: Swatinem/rust-cache@v2
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ env.TARGET }}
- uses: actions/upload-artifact@v4
with:
name: gui-installer-msi
path: target/${{ env.TARGET }}/release/bundle/msi/*.msi
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-exe
path: target/${{ env.TARGET }}/release/bundle/nsis/*.exe
if-no-files-found: error
build_release_zip:
permissions:
contents: read
@@ -334,6 +594,8 @@ jobs:
- windows-x86_64
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon
@@ -343,7 +605,7 @@ jobs:
- name: Setup versioned release directory
run: |
platform="${{ matrix.platform }}"
dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
dest="rayhunter-v${VERSION}-${{ matrix.platform }}"
mkdir "$dest"
# Handle installer with proper extension for Windows
if [ "$platform" = "windows-x86_64" ]; then
@@ -351,7 +613,7 @@ jobs:
else
mv installer-$platform/installer "$dest"/installer
fi
cp -r rayhunter-check-* rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
cp -r rayhunter-check-* rayhunter-daemon dist/scripts "$dest"/
zip -r "$dest.zip" "$dest"
sha256sum "$dest.zip" > "$dest.zip.sha256"
@@ -363,3 +625,57 @@ jobs:
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
if-no-files-found: error
openapi_build:
if: needs.files_changed.outputs.docs_changed == 'true'
needs:
- files_changed
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-daemon openapi docs
run: |
mkdir -p daemon/web/build
touch daemon/web/build/{favicon.png,index.html.gz,rayhunter_orca_only.png,rayhunter_text.png}
cargo run --bin gen_api --features apidocs -- ./rayhunter-openapi.json
- name: Make swagger folder
run: |
mkdir api-docs
mv doc/swagger-ui.html api-docs/index.html
mv rayhunter-openapi.json api-docs/
- uses: actions/upload-artifact@v4
with:
name: api-docs
path: api-docs
github_pages_publish:
name: Upload new documentation to Github Pages
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
needs:
- mdbook_build
- openapi_build
runs-on: ubuntu-latest
steps:
- name: Setup Pages
uses: actions/configure-pages@v4
- uses: actions/download-artifact@v4
- name: Organize pages into directory
run: cp -a api-docs book/
- name: Upload pages
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy Github Pages
uses: actions/deploy-pages@v4

View File

@@ -14,10 +14,12 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Ensure all Cargo.toml files have the same version defined.
run: |
defined_versions=$(find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
defined_versions=$(find lib check daemon installer installer-gui rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
find lib check daemon installer installer-gui rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
echo number of defined versions = $defined_versions
if [ $defined_versions != "1" ]
then
@@ -41,6 +43,8 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- name: Create release
run: |

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/target
/book
.DS_Store
/tools/build-wpa-supplicant

View File

@@ -58,15 +58,25 @@ Otherwise:
manually test them. Our test coverage isn't great, but as new features are
added we are trying to prevent it from becoming worse.
- Please keep your contributions to less than approximately 400 lines of code not counting tests, (going slightly over is fine, we aren't dogmatic about it.) This is because we are not able to give quality code review to contributions larger than that and risk introducing bugs into the system. [There was a study showing 400 LOC is the max most humans can handle.](https://smartbear.com/learn/code-review/best-practices-for-peer-code-review/)
If you have any questions [feel free to open a discussion or chat with us on Mattermost.](https://efforg.github.io/rayhunter/support-feedback-community.html)
### Policy regarding AI-generated contributions:
- Please refrain from submissions that you haven't thoroughly understood, reviewed, and tested.
- Please disclose if your contribution was AI-generated
- Descriptions and comments should be made by you
You can read our [full policy](https://www.eff.org/about/opportunities/volunteer/coding-with-eff) and some writing on [our motivations](https://www.eff.org/deeplinks/2026/02/effs-policy-llm-assisted-contributions-our-open-source-projects).
## Making releases
This one is for maintainers of Rayhunter.
1. Make a PR changing the versions in `Cargo.toml` and other files.
This could be automated better but right now it's manual. You can do this easily with sed:
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml`
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml installer-gui/src-tauri/Cargo.toml`
2. Merge PR and make a tag.

4039
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,7 +3,7 @@
![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.html).
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts, it can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html).
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
&rarr; Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started.

View File

@@ -6,3 +6,4 @@ title = "Rayhunter - An IMSI Catcher Catcher"
[output.html]
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
additional-css = ["doc/custom.css"]

View File

@@ -1,6 +1,6 @@
[package]
name = "rayhunter-check"
version = "0.8.0"
version = "0.11.2"
edition = "2024"
[dependencies]
@@ -10,5 +10,4 @@ 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"

View File

@@ -155,10 +155,10 @@ async fn pcapify(qmdl_path: &PathBuf) {
.await
.expect("failed to get container")
{
for msg in container.into_messages().into_iter().flatten() {
for msg in container.messages().into_iter().flatten() {
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
pcap_writer
.write_gsmtap_message(parsed, timestamp)
.write_gsmtap_message(parsed, timestamp, None)
.await
.expect("failed to write");
}
@@ -177,14 +177,7 @@ async fn main() {
} 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();
rayhunter::init_logging(level);
let harness = Harness::new_with_config(&AnalyzerConfig::default());
info!("Analyzers:");

View File

@@ -1,24 +1,35 @@
[package]
name = "rayhunter-daemon"
version = "0.8.0"
version = "0.11.2"
edition = "2024"
rust-version = "1.88.0"
[lib]
name = "rayhunter_daemon"
path = "src/lib.rs"
[[bin]]
name = "gen_api"
path = "src/bin/gen_api.rs"
required-features = ["apidocs"]
[features]
default = ["rustcrypto-tls"]
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
ring-tls = ["reqwest/rustls-tls-webpki-roots"]
pq-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-post-quantum"]
apidocs = ["dep:utoipa", "wifi-station/utoipa"]
[dependencies]
rayhunter = { path = "../lib" }
wifi-station = "0.10.1"
toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] }
serde_repr = "0.1"
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
thiserror = "1.0.52"
libc = "0.2.150"
log = "0.4.20"
env_logger = { version = "0.11", default-features = false }
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
futures-macro = "0.3.30"
include_dir = "0.7.3"
@@ -27,10 +38,16 @@ tokio-stream = { version = "0.1.14", default-features = false, features = ["io-u
futures = { version = "0.3.30", default-features = false }
serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1"
tempfile = "3.10.2"
async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false }
reqwest = { version = "0.12.20", default-features = false, features = ["stream"] }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
rustls-post-quantum = { version = "0.2.4", optional = true }
async-trait = "0.1.88"
uds = { version = "0.4.2", features = ["tokio"] }
utoipa = { version = "5.4.0", optional = true }
url = "2.5.4"
[dev-dependencies]
reqwest = { version = "0.12.20", default-features = false, features = ["gzip"] }
tempfile = "3.10.2"

View File

@@ -18,7 +18,7 @@ use tokio::sync::mpsc::Receiver;
use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio_util::task::TaskTracker;
use crate::qmdl_store::RecordingStore;
use crate::qmdl_store::{FileKind, RecordingStore};
use crate::server::ServerState;
pub struct AnalysisWriter {
@@ -77,10 +77,15 @@ impl AnalysisWriter {
}
}
/// The system status relating to QMDL file analysis
#[derive(Debug, Serialize, Clone)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct AnalysisStatus {
/// The vector array of queued files
queued: Vec<String>,
/// The file currently being analyzed
running: Option<String>,
/// The vector array of finished files
finished: Vec<String>,
}
@@ -140,9 +145,10 @@ async fn perform_analysis(
.await
.map_err(|e| format!("{e:?}"))?;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.open_file(entry_index, FileKind::Qmdl)
.await
.map_err(|e| format!("{e:?}"))?;
.map_err(|e| format!("{e:?}"))?
.ok_or("QMDL file not found")?;
(analysis_file, qmdl_file)
};
@@ -215,6 +221,16 @@ pub fn run_analysis_thread(
});
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/analysis",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "Success", body = AnalysisStatus)
),
summary = "Analysis status",
description = "Show analysis status for all QMDL files."
))]
pub async fn get_analysis_status(
State(state): State<Arc<ServerState>>,
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
@@ -231,6 +247,20 @@ fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>
true
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/analysis/{name}",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Unable to queue analysis file")
),
params(
("name" = String, Path, description = "QMDL file to analyze")
),
summary = "Start analysis",
description = "Begin analysis of QMDL file {name}."
))]
pub async fn start_analysis(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,

View File

@@ -1,6 +1,6 @@
use std::{path::Path, time::Duration};
use log::{error, info};
use log::{info, warn};
use rayhunter::Device;
use serde::Serialize;
use tokio::select;
@@ -18,9 +18,13 @@ pub mod wingtech;
const LOW_BATTERY_LEVEL: u8 = 10;
/// Device battery information
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct BatteryState {
/// The current level in percentage of the device battery
level: u8,
/// A boolean indicating whether the battery is currently being charged
is_plugged_in: bool,
}
@@ -66,11 +70,11 @@ pub fn run_battery_notification_worker(
// Don't send a notification initially if the device starts at a low battery level.
let mut triggered = match get_battery_status(&device).await {
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
info!("Battery level function not supported for device");
false
info!("Battery status not supported for this device, disabling battery notifications");
return;
}
Err(e) => {
error!("Failed to get battery status: {e}");
warn!("Failed to get battery status: {e}");
true
}
Ok(status) => status.level <= LOW_BATTERY_LEVEL,
@@ -83,8 +87,12 @@ pub fn run_battery_notification_worker(
}
let status = match get_battery_status(&device).await {
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
info!("Battery status not supported for this device, disabling battery notifications");
break;
}
Err(e) => {
error!("Failed to get battery status: {e}");
warn!("Failed to get battery status: {e}");
continue;
}
Ok(status) => status,

12
daemon/src/bin/gen_api.rs Normal file
View File

@@ -0,0 +1,12 @@
use std::{env, fs};
fn main() {
let content = rayhunter_daemon::ApiDocs::generate();
let mut filename = "openapi.json".to_string();
let args: Vec<String> = env::args().collect();
if args.len() > 1 {
filename = args[1].to_string();
}
fs::write(filename, content).unwrap();
}

View File

@@ -1,25 +1,126 @@
use log::warn;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use rayhunter::Device;
use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum GpsMode {
Disabled = 0,
Fixed = 1,
Api = 2,
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum UiLevel {
Invisible = 0,
Subtle = 1,
Demo = 2,
EffLogo = 3,
HighVisibility = 4,
TransFlag = 128,
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum KeyInputMode {
Disabled = 0,
DoubleTapPower = 1,
}
use crate::notifications::NotificationType;
/// The structure of a valid rayhunter configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct Config {
/// Path to store QMDL files
pub qmdl_store_path: String,
/// Listening port
pub port: u16,
/// Debug mode
pub debug_mode: bool,
/// Internal device name
pub device: Device,
pub ui_level: u8,
/// UI level
pub ui_level: UiLevel,
/// Colorblind mode
pub colorblind_mode: bool,
pub key_input_mode: u8,
/// Key input mode
pub key_input_mode: KeyInputMode,
/// ntfy.sh URL
pub ntfy_url: Option<String>,
/// Vector containing the types of enabled notifications
pub enabled_notifications: Vec<NotificationType>,
/// Whether Rayhunter should periodically check GitHub for new releases
pub auto_check_updates: bool,
/// Vector containing the list of enabled analyzers
pub analyzers: AnalyzerConfig,
/// Minimum disk space required to start a recording
pub min_space_to_start_recording_mb: u64,
/// Minimum disk space required to continue a recording
pub min_space_to_continue_recording_mb: u64,
/// GPS mode
pub gps_mode: GpsMode,
/// Fixed latitude used when gps_mode=1
pub gps_fixed_latitude: Option<f64>,
/// Fixed longitude used when gps_mode=1
pub gps_fixed_longitude: Option<f64>,
/// Wifi client SSID
pub wifi_ssid: Option<String>,
/// Wifi client password
pub wifi_password: Option<String>,
/// Wifi security type (wpa_psk or sae)
pub wifi_security: Option<wifi_station::SecurityType>,
/// Wifi client mode
pub wifi_enabled: bool,
/// Vector containing wifi client DNS servers
pub dns_servers: Option<Vec<String>>,
/// WebDAV upload configuration. The upload worker runs whenever `webdav.url` is non-empty.
pub webdav: WebdavConfig,
}
/// Configuration for uploading finished QMDL recordings to a WebDAV server.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct WebdavConfig {
/// WebDAV server base URL, e.g. "https://example.com/remote.php/files/untitaker/my-subfolder/"
pub url: String,
/// Optional username for HTTP Basic auth
pub username: Option<String>,
/// Optional password for HTTP Basic auth
pub password: Option<String>,
/// Timeout (in seconds) for each upload request
pub upload_timeout_secs: u64,
/// How often (in seconds) the worker scans for entries to upload
pub poll_interval_secs: u64,
/// Minimum age (in seconds) an entry must have before it becomes eligible for upload
pub min_age_secs: i64,
/// Delete the file locally after a successful upload
pub delete_on_upload: bool,
}
impl Default for WebdavConfig {
fn default() -> Self {
WebdavConfig {
url: String::new(),
username: None,
password: None,
upload_timeout_secs: 300,
poll_interval_secs: 3600,
min_age_secs: 86400,
delete_on_upload: false,
}
}
}
impl Default for Config {
@@ -29,26 +130,94 @@ impl Default for Config {
port: 8080,
debug_mode: false,
device: Device::Orbic,
ui_level: 1,
ui_level: UiLevel::Subtle,
colorblind_mode: false,
key_input_mode: 0,
key_input_mode: KeyInputMode::Disabled,
analyzers: AnalyzerConfig::default(),
ntfy_url: None,
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
auto_check_updates: true,
min_space_to_start_recording_mb: 1,
min_space_to_continue_recording_mb: 1,
gps_mode: GpsMode::Disabled,
gps_fixed_latitude: None,
gps_fixed_longitude: None,
wifi_ssid: None,
wifi_password: None,
wifi_security: None,
wifi_enabled: false,
dns_servers: None,
webdav: WebdavConfig::default(),
}
}
}
impl Config {
pub fn wifi_config(&self) -> wifi_station::WifiConfig {
let (wpa_bin, hostapd_conf, ctrl_interface) = match self.device {
Device::Tmobile | Device::Wingtech => (
Some("/usr/sbin/wpa_supplicant".into()),
Some("/data/configs/hostapd.conf".into()),
None,
),
Device::Uz801 => (
Some("/system/bin/wpa_supplicant".into()),
Some("/data/misc/wifi/hostapd.conf".into()),
Some("/data/misc/wifi/sockets".into()),
),
_ => (None, None, None),
};
wifi_station::WifiConfig {
wifi_enabled: self.wifi_enabled,
dns_servers: self.dns_servers.clone(),
wifi_ssid: self.wifi_ssid.clone(),
wifi_password: self.wifi_password.clone(),
security_type: self.wifi_security,
wpa_supplicant_bin: wpa_bin.or_else(|| resolve_bin("wpa_supplicant")),
hostapd_conf,
ctrl_interface,
udhcpc_hook_path: Some("/data/rayhunter/udhcpc-hook.sh".into()),
dhcp_lease_path: Some("/data/rayhunter/dhcp_lease".into()),
wpa_conf_path: Some("/data/rayhunter/wpa_sta.conf".into()),
iw_bin: resolve_bin("iw"),
udhcpc_bin: resolve_bin("udhcpc"),
crash_log_dir: Some("/data/rayhunter/crash-logs".into()),
wakelock_name: Some("rayhunter".into()),
}
}
}
fn resolve_bin(name: &str) -> Option<String> {
let local = format!("/data/rayhunter/bin/{name}");
if std::path::Path::new(&local).exists() {
return Some(local);
}
None
}
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
where
P: AsRef<std::path::Path>,
{
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
let mut config = if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?
} else {
warn!("unable to read config file, using default config");
Ok(Config::default())
Config::default()
};
if let Some((ssid, security)) =
wifi_station::read_network_from_wpa_conf("/data/rayhunter/wpa_sta.conf")
{
config.wifi_ssid = Some(ssid);
config.wifi_security = Some(security);
} else {
config.wifi_ssid = None;
config.wifi_security = None;
}
config.wifi_password = None;
Ok(config)
}
pub struct Args {

View File

@@ -0,0 +1,23 @@
use std::sync::Once;
static INSTALL: Once = Once::new();
/// Install the default rustls `CryptoProvider` for the current process.
///
/// This is idempotent so that it's easier to use in tests, but also panics loudly if the
/// initialization fails.
pub fn install_default() {
// Crypto providers fail if they get initialized multiple times, but we don't want to just
// ignore all errors, hence the use of once.
INSTALL.call_once(|| {
#[cfg(feature = "rustcrypto-tls")]
rustls_rustcrypto::provider()
.install_default()
.expect("failed to install rustcrypto crypto provider");
#[cfg(feature = "pq-tls")]
rustls_post_quantum::provider()
.install_default()
.expect("failed to install aws-lc-rs post-quantum crypto provider");
});
}

View File

@@ -10,27 +10,38 @@ use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt, future};
use log::{debug, error, info, warn};
use rayhunter::Device;
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::gps::GpsRecord;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream;
use tokio_util::task::TaskTracker;
#[cfg(feature = "apidocs")]
use rayhunter::analysis::analyzer::ReportMetadata;
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag::{DataType, Message, MessagesContainer};
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::config::GpsMode;
use crate::display;
use crate::notifications::{Notification, NotificationType};
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::qmdl_store::{FileKind, RecordingStore, RecordingStoreError};
use crate::server::ServerState;
use crate::stats::DiskStats;
const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording,
StartRecording {
response_tx: Option<oneshot::Sender<Result<(), RecordingStoreError>>>,
},
DeleteEntry {
name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
@@ -38,6 +49,10 @@ pub enum DiagDeviceCtrlMessage {
DeleteAllEntries {
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
GpsUpdate {
lat: f64,
lon: f64,
},
Exit,
}
@@ -46,8 +61,15 @@ pub struct DiagTask {
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
gps_mode: GpsMode,
gps_fixed_coords: Option<(f64, f64)>,
state: DiagState,
max_type_seen: EventType,
bytes_since_space_check: usize,
low_space_warned: bool,
latest_packet_timestamp: Option<i64>,
}
enum DiagState {
@@ -58,39 +80,123 @@ enum DiagState {
Stopped,
}
enum DiskSpaceCheck {
Ok(u64),
Warning(u64),
Critical(u64),
Failed,
}
fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -> DiskSpaceCheck {
match DiskStats::new(path.to_str().unwrap()) {
Ok(stats) => {
let available_mb = stats.available_bytes.unwrap_or(0) / 1024 / 1024;
if available_mb < critical_mb {
DiskSpaceCheck::Critical(available_mb)
} else if available_mb < warning_mb {
DiskSpaceCheck::Warning(available_mb)
} else {
DiskSpaceCheck::Ok(available_mb)
}
}
Err(e) => {
warn!("Failed to check disk space: {e}");
DiskSpaceCheck::Failed
}
}
}
impl DiagTask {
#[allow(clippy::too_many_arguments)]
fn new(
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
gps_mode: GpsMode,
gps_fixed_coords: Option<(f64, f64)>,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
min_space_to_start_mb,
min_space_to_continue_mb,
gps_mode,
gps_fixed_coords,
state: DiagState::Stopped,
max_type_seen: EventType::Informational,
bytes_since_space_check: 0,
low_space_warned: false,
latest_packet_timestamp: None,
}
}
/// 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");
/// Start recording, returning an error if disk space is too low.
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), RecordingStoreError> {
self.max_type_seen = EventType::Informational;
self.bytes_since_space_check = 0;
self.low_space_warned = false;
match check_disk_space(
&qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
return Err(RecordingStoreError::InsufficientDiskSpace(
mb,
self.min_space_to_start_mb,
));
}
DiskSpaceCheck::Ok(mb) => {
info!("Starting recording with {}MB disk space available", mb);
}
DiskSpaceCheck::Failed => {}
}
let (qmdl_file, analysis_file) = qmdl_store.new_entry(self.gps_mode).await?;
// For fixed-mode sessions, write the configured coordinates to the storage
// immediately so the per-session GPS is stored durably and isn't affected
// by future config changes or GPS API calls.
if self.gps_mode == GpsMode::Fixed
&& let Some((lat, lon)) = self.gps_fixed_coords
&& let Some((entry_idx, _)) = qmdl_store.get_current_entry()
{
let mut gps_file = qmdl_store
.open_entry_gps_for_append(entry_idx)
.await?
.ok_or(RecordingStoreError::GpsStorageNotFound)?;
let record = GpsRecord {
latest_packet_timestamp: None,
system_time: rayhunter::clock::get_adjusted_now().timestamp(),
lat,
lon,
};
let mut json = serde_json::to_vec(&record)?;
json.push(b'\n');
gps_file
.write_all(&json)
.await
.map_err(RecordingStoreError::WriteFileError)?;
}
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");
.map_err(RecordingStoreError::WriteFileError)?;
self.state = DiagState::Recording {
qmdl_writer,
analysis_writer,
analysis_writer: Box::new(analysis_writer),
};
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Recording)
@@ -98,11 +204,17 @@ impl DiagTask {
{
warn!("couldn't send ui update message: {e}");
}
Ok(())
}
/// Stop recording
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
/// Stop recording, optionally annotating the entry with a reason.
async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
self.stop_current_recording().await;
if let Some(reason) = reason
&& let Err(e) = qmdl_store.set_current_stop_reason(reason).await
{
warn!("couldn't set stop reason: {e}");
}
if let Some((_, entry)) = qmdl_store.get_current_entry()
&& let Err(e) = self
.analysis_sender
@@ -131,7 +243,7 @@ impl DiagTask {
name: &str,
) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store).await;
self.stop(qmdl_store, None).await;
}
let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() {
@@ -144,7 +256,7 @@ impl DiagTask {
&mut self,
qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store).await;
self.stop(qmdl_store, None).await;
let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}");
@@ -152,6 +264,38 @@ impl DiagTask {
res
}
async fn handle_gps_update(&mut self, qmdl_store: &RecordingStore, lat: f64, lon: f64) {
let Some((entry_idx, _)) = qmdl_store.get_current_entry() else {
info!("GPS update received but no recording active, not writing to storage");
return;
};
let mut file = match qmdl_store.open_entry_gps_for_append(entry_idx).await {
Ok(Some(f)) => f,
Ok(None) => {
error!("GPS storage not found, cannot write GPS record");
return;
}
Err(e) => {
error!("failed to open GPS storage: {e}");
return;
}
};
let record = GpsRecord {
latest_packet_timestamp: self.latest_packet_timestamp,
system_time: rayhunter::clock::get_adjusted_now().timestamp(),
lat,
lon,
};
let Ok(mut json) = serde_json::to_vec(&record) else {
error!("failed to serialize GPS record");
return;
};
json.push(b'\n');
if let Err(e) = file.write_all(&json).await {
error!("failed to write GPS record to storage: {e}");
}
}
async fn stop_current_recording(&mut self) {
let mut state = DiagState::Stopped;
std::mem::swap(&mut self.state, &mut state);
@@ -182,10 +326,54 @@ impl DiagTask {
analysis_writer,
} = &mut self.state
{
qmdl_writer
.write_container(&container)
.await
.expect("failed to write to QMDL writer");
if self.bytes_since_space_check >= DISK_CHECK_BYTES_INTERVAL {
self.bytes_since_space_check = 0;
match check_disk_space(
&qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) => {
let reason = format!(
"Disk space critically low ({}MB free), recording stopped automatically",
mb
);
error!("{reason}");
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
reason.clone(),
None,
))
.await
.ok();
self.stop(qmdl_store, Some(reason)).await;
return;
}
DiskSpaceCheck::Warning(mb) if !self.low_space_warned => {
self.low_space_warned = true;
warn!("Disk space low: {}MB remaining", mb);
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Disk space low: {}MB free", mb),
Some(Duration::from_secs(30)),
))
.await
.ok();
}
_ => {}
}
}
if let Err(e) = qmdl_writer.write_container(&container).await {
let reason = format!("failed to write to QMDL (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!(
"total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written
@@ -193,15 +381,39 @@ impl DiagTask {
let index = qmdl_store
.current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store
if let Err(e) = qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written)
.await
.expect("failed to update qmdl file size");
{
let reason = format!("failed to update manifest (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!("done!");
let max_type = analysis_writer
.analyze(container)
.await
.expect("failed to analyze container");
// Extract the latest packet timestamp from this container
if let Some(ts) = container
.messages()
.into_iter()
.filter_map(|r| match r {
Ok(Message::Log { timestamp, .. }) => Some(timestamp.to_datetime().timestamp()),
_ => None,
})
.max()
{
self.latest_packet_timestamp = Some(ts);
}
let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
self.bytes_since_space_check += container_bytes;
let max_type = match analysis_writer.analyze(container).await {
Ok(t) => t,
Err(e) => {
warn!("failed to analyze container: {e}");
EventType::Informational
}
};
if max_type > EventType::Informational {
info!("a heuristic triggered on this run!");
@@ -235,7 +447,7 @@ impl DiagTask {
#[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
device: Device,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>,
@@ -243,25 +455,47 @@ pub fn run_diag_read_thread(
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
gps_mode: GpsMode,
gps_fixed_coords: Option<(f64, f64)>,
) {
task_tracker.spawn(async move {
info!("Using configuration for device: {0:?}", device);
let mut dev = DiagDevice::new(&device)
.await?;
dev.config_logs()
.await?;
let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel);
let mut diag_task = DiagTask::new(
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
min_space_to_start_mb,
min_space_to_continue_mb,
gps_mode,
gps_fixed_coords,
);
qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording)
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await
.unwrap();
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording) => {
Some(DiagDeviceCtrlMessage::StartRecording { response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.start(qmdl_store.deref_mut()).await;
let result = diag_task.start(qmdl_store.deref_mut()).await;
if let Some(tx) = response_tx {
tx.send(result).ok();
}
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.stop(qmdl_store.deref_mut()).await;
diag_task.stop(qmdl_store.deref_mut(), None).await;
},
// None means all the Senders have been dropped, so it's
// time to go
@@ -284,6 +518,10 @@ pub fn run_diag_read_thread(
error!("Failed to send delete all entries respons, receiver dropped");
}
},
Some(DiagDeviceCtrlMessage::GpsUpdate { lat, lon }) => {
let qmdl_store = qmdl_store_lock.read().await;
diag_task.handle_gps_update(&qmdl_store, lat, lon).await;
},
}
}
maybe_container = diag_stream.next() => {
@@ -304,6 +542,18 @@ pub fn run_diag_read_thread(
}
/// Start recording API for web thread
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/start-recording",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
),
summary = "Start recording",
description = "Begin a new data capture."
))]
pub async fn start_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -311,9 +561,12 @@ pub async fn start_recording(
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::StartRecording)
.send(DiagDeviceCtrlMessage::StartRecording {
response_tx: Some(response_tx),
})
.await
.map_err(|e| {
(
@@ -322,10 +575,29 @@ pub async fn start_recording(
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
match response_rx.await {
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason.to_string())),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive start recording response: {e}"),
)),
}
}
/// Stop recording API for web thread
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/stop-recording",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
),
summary = "Stop recording",
description = "Stop current data capture."
))]
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -345,6 +617,22 @@ pub async fn stop_recording(
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/delete-recording/{name}",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful"),
(status = StatusCode::BAD_REQUEST, description = "Bad recording name or no such recording")
),
params(
("name" = String, Path, description = "QMDL file to delete")
),
summary = "Delete recording",
description = "Remove data capture file named {name}."
))]
pub async fn delete_recording(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
@@ -384,6 +672,18 @@ pub async fn delete_recording(
}
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/delete-all-recordings",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful")
),
summary = "Delete all recordings",
description = "Remove all saved data capture files."
))]
pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -415,6 +715,21 @@ pub async fn delete_all_recordings(
}
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/analysis-report/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "Success", body = ReportMetadata, content_type = "application/x-ndjson"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "No QMDL files available; start a new recording."),
(status = StatusCode::NOT_FOUND, description = "File {name} not found")
),
params(
("name" = String, Path, description = "QMDL file to analyze")
),
summary = "Analysis report",
description = "Download processed analysis report for QMDL file {name}, as well as the types (and versions) of analyzers used."
))]
pub async fn get_analysis_report(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
@@ -432,9 +747,10 @@ pub async fn get_analysis_report(
))?
};
let analysis_file = qmdl_store
.open_entry_analysis(entry_index)
.open_file(entry_index, FileKind::Analysis)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
.ok_or((StatusCode::NOT_FOUND, "Analysis file not found".to_string()))?;
// Read and normalize the NDJSON file
let reader = BufReader::new(analysis_file);

View File

@@ -3,7 +3,7 @@ use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::F
use std::io::Cursor;
use std::time::Duration;
use crate::config;
use crate::config::{self, UiLevel};
use crate::display::DisplayState;
use rayhunter::analysis::analyzer::EventType;
@@ -102,7 +102,7 @@ pub trait GenericFramebuffer: Send + 'static {
resized_img = img;
}
let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::new();
let mut buf = Vec::with_capacity((height * width).try_into().unwrap());
for y in 0..height {
for x in 0..width {
let px = img_rgba8.get_pixel(x, y);
@@ -145,7 +145,7 @@ pub trait GenericFramebuffer: Send + 'static {
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
let width = self.dimensions().width;
let mut buffer = Vec::new();
let mut buffer = Vec::with_capacity((height * width).try_into().unwrap());
for _row in 0..height {
for col in 0..width {
@@ -176,8 +176,9 @@ pub fn update_ui(
) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
let display_level = config.ui_level;
if display_level == 0 {
if display_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI.");
return;
}
let colorblind_mode = config.colorblind_mode;
@@ -186,14 +187,14 @@ pub fn update_ui(
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 {
if display_level == UiLevel::Demo {
img = Some(
IMAGE_DIR
.get_file("orca.gif")
.expect("failed to read orca.gif")
.contents(),
);
} else if display_level == 3 {
} else if display_level == UiLevel::EffLogo {
img = Some(
IMAGE_DIR
.get_file("eff.png")
@@ -214,22 +215,26 @@ pub fn update_ui(
Err(e) => error!("error receiving framebuffer update message: {e}"),
}
let mut status_bar_height = 2;
match display_level {
2 => fb.draw_gif(img.unwrap()).await,
3 => fb.draw_img(img.unwrap()).await,
128 => {
UiLevel::Demo => fb.draw_gif(img.unwrap()).await,
UiLevel::EffLogo => fb.draw_img(img.unwrap()).await,
UiLevel::HighVisibility => {
status_bar_height = fb.dimensions().height;
}
UiLevel::TransFlag => {
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
// UiLevel::Subtle (1) and anything else: just the status bar line
_ => {}
};
let (color, pattern) = display_style;
fb.draw_patterned_line(color, 2, pattern).await;
fb.draw_patterned_line(color, status_bar_height, pattern)
.await;
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
}
});

View File

@@ -12,7 +12,9 @@ pub mod tplink_onebit;
pub mod uz801;
pub mod wingtech;
/// A list of available display states
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum DisplayState {
/// We're recording but no warning has been found yet.
Recording,

View File

@@ -23,7 +23,7 @@ impl GenericFramebuffer for Framebuffer {
}
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::new();
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;

View File

@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
use std::time::Duration;
use crate::config;
use crate::config::{self, UiLevel};
use crate::display::DisplayState;
macro_rules! led {
@@ -31,7 +31,7 @@ pub fn update_ui(
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
if config.ui_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI.");
invisible = true;
}

View File

@@ -3,7 +3,7 @@ use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::config::{self, UiLevel};
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
use std::fs;
@@ -15,7 +15,7 @@ pub fn update_ui(
ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
if display_level == 0 {
if display_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI.");
}

View File

@@ -50,7 +50,7 @@ impl GenericFramebuffer for Framebuffer {
rop: 0,
};
let mut raw_buffer = Vec::new();
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;

View File

@@ -1,7 +1,7 @@
/// Display module for the TP-Link M7350 oled one-bit display.
///
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
use crate::config;
use crate::config::{self, UiLevel};
use crate::display::DisplayState;
use log::{error, info};
@@ -115,7 +115,7 @@ pub fn update_ui(
mut ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
if display_level == 0 {
if display_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI.");
}
@@ -140,7 +140,7 @@ pub fn update_ui(
// we write the status every second because it may have been overwritten through menu
// navigation.
if display_level != 0
if display_level != UiLevel::Invisible
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
{
error!("failed to write to display: {e}");

View File

@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
use std::time::Duration;
use crate::config;
use crate::config::{self, UiLevel};
use crate::display::DisplayState;
macro_rules! led {
@@ -31,7 +31,7 @@ pub fn update_ui(
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
if config.ui_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI.");
invisible = true;
}

View File

@@ -28,7 +28,7 @@ impl GenericFramebuffer for Framebuffer {
}
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::new();
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;

View File

@@ -1,4 +1,3 @@
use rayhunter::diag_device::DiagDeviceError;
use thiserror::Error;
use crate::qmdl_store::RecordingStoreError;
@@ -7,8 +6,6 @@ use crate::qmdl_store::RecordingStoreError;
pub enum RayhunterError {
#[error("Config file parsing error: {0}")]
ConfigFileParsingError(#[from] toml::de::Error),
#[error("Diag intialization error: {0}")]
DiagInitError(DiagDeviceError),
#[error("Tokio error: {0}")]
TokioError(#[from] tokio::io::Error),
#[error("QmdlStore error: {0}")]

151
daemon/src/gps.rs Normal file
View File

@@ -0,0 +1,151 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use log::{error, warn};
use serde::{Deserialize, Deserializer, Serialize};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use crate::config::GpsMode;
use crate::diag::DiagDeviceCtrlMessage;
use crate::server::ServerState;
fn deserialize_latitude<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
use serde::de;
let v = f64::deserialize(deserializer)?;
if !(-90.0..=90.0).contains(&v) {
return Err(de::Error::custom(format!(
"latitude {v} out of range [-90, 90]"
)));
}
Ok(v)
}
fn deserialize_longitude<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
use serde::de;
let v = f64::deserialize(deserializer)?;
if !(-180.0..=180.0).contains(&v) {
return Err(de::Error::custom(format!(
"longitude {v} out of range [-180, 180]"
)));
}
Ok(v)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct GpsData {
#[serde(deserialize_with = "deserialize_latitude")]
pub latitude: f64,
#[serde(deserialize_with = "deserialize_longitude")]
pub longitude: f64,
}
#[derive(Serialize, Deserialize)]
pub struct GpsRecord {
/// Packet timestamp (modem clock) for correlation with captured packets.
/// None if no packets have been received yet.
pub latest_packet_timestamp: Option<i64>,
/// Drift-corrected system time when this GPS fix was received
pub system_time: i64,
pub lat: f64,
pub lon: f64,
}
/// Reads all GPS records from a storage NDJSON file, logging and skipping malformed lines.
pub async fn load_gps_records(file: tokio::fs::File) -> Vec<GpsRecord> {
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut records = Vec::new();
loop {
match lines.next_line().await {
Ok(Some(line)) => match serde_json::from_str::<GpsRecord>(&line) {
Ok(record) => records.push(record),
Err(e) => warn!("skipping malformed GPS storage line: {e}"),
},
Ok(None) => break,
Err(e) => {
error!("error reading GPS storage file: {e}");
break;
}
}
}
records.sort_by_key(|r| r.latest_packet_timestamp.unwrap_or(i64::MIN));
records
}
/// Submit GPS coordinates
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/gps",
tag = "Configuration",
request_body = GpsData,
responses(
(status = StatusCode::OK, description = "GPS data accepted"),
(status = StatusCode::FORBIDDEN, description = "GPS API endpoint is disabled"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send GPS update")
),
summary = "Submit GPS coordinates",
description = "Submit GPS coordinates from an external source (e.g. a phone app). Requires gps_mode to be set to 'Api' in configuration. latitude is in decimal degrees from -90 to 90, longitude is in decimal degrees from -180 to 180. The timestamp is derived from the most recent packet's modem timestamp."
))]
pub async fn post_gps(
State(state): State<Arc<ServerState>>,
Json(gps_data): Json<GpsData>,
) -> Result<StatusCode, (StatusCode, String)> {
if state.config.gps_mode != GpsMode::Api {
return Err((
StatusCode::FORBIDDEN,
"GPS API endpoint is disabled. Set gps_mode to API endpoint in configuration."
.to_string(),
));
}
// Update in-memory state for GET /api/gps
let mut gps = state.gps_state.write().await;
*gps = Some(gps_data.clone());
drop(gps);
// Send to DiagTask to write to storage with packet timestamp
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::GpsUpdate {
lat: gps_data.latitude,
lon: gps_data.longitude,
})
.await
.map_err(|e| {
error!("failed to send GPS update: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to send GPS update: {e}"),
)
})?;
Ok(StatusCode::OK)
}
/// Get the current GPS coordinates
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/gps",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Current GPS data", body = GpsData),
(status = StatusCode::NOT_FOUND, description = "No GPS data available")
),
summary = "Get current GPS coordinates",
description = "Returns the most recently submitted GPS coordinates. Returns 404 if no coordinates have been submitted yet this session."
))]
pub async fn get_gps(State(state): State<Arc<ServerState>>) -> Result<Json<GpsData>, StatusCode> {
let gps = state.gps_state.read().await;
match gps.as_ref() {
Some(data) => Ok(Json(data.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}

View File

@@ -6,7 +6,7 @@ use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::config::{self, KeyInputMode};
use crate::diag::DiagDeviceCtrlMessage;
#[derive(Debug)]
@@ -23,7 +23,7 @@ pub fn run_key_input_thread(
diag_tx: Sender<DiagDeviceCtrlMessage>,
cancellation_token: CancellationToken,
) {
if config.key_input_mode == 0 {
if config.key_input_mode == KeyInputMode::Disabled {
return;
}
@@ -81,8 +81,9 @@ pub fn run_key_input_thread(
{
error!("Failed to send StopRecording: {e}");
}
if let Err(e) =
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
if let Err(e) = diag_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await
{
error!("Failed to send StartRecording: {e}");
}

78
daemon/src/lib.rs Normal file
View File

@@ -0,0 +1,78 @@
pub mod analysis;
pub mod battery;
pub mod config;
pub mod crypto_provider;
pub mod diag;
pub mod display;
pub mod error;
pub mod gps;
pub mod key_input;
pub mod notifications;
pub mod pcap;
pub mod qmdl_store;
pub mod server;
pub mod stats;
pub mod update;
pub mod webdav;
#[cfg(feature = "apidocs")]
use utoipa::OpenApi;
// Add anotated paths to api docs
#[cfg(feature = "apidocs")]
#[derive(OpenApi)]
#[openapi(
info(
description = "OpenAPI documentation for Rayhunter daemon\n\n**Note:** API endpoints are subject to change as needs arise, though we will try to keep them as stable as possible and notify about breaking changes in the changelogs for new versions.\n\nNo endpoints require any authentication. To use the in-browser execution on this page, you may need to disable CORS temporarily for your browser.",
license(
name = "GNU General Public License v3.0",
url = "https://github.com/EFForg/rayhunter/blob/main/LICENSE"
)
),
paths(
pcap::get_pcap,
server::get_qmdl,
server::get_zip,
stats::get_system_stats,
stats::get_qmdl_manifest,
stats::get_update_status,
stats::get_log,
diag::start_recording,
diag::stop_recording,
diag::delete_recording,
diag::delete_all_recordings,
diag::get_analysis_report,
analysis::get_analysis_status,
analysis::start_analysis,
server::get_config,
server::set_config,
server::test_notification,
server::get_time,
server::set_time_offset,
server::debug_set_display_state,
gps::post_gps,
gps::get_gps
),
servers(
(
url = "http://localhost:8080",
description = "ADB port bridge"
),
(
url = "http://192.168.1.1:8080",
description = "Orbic WiFi GUI"
),
(
url = "http://192.168.0.1:8080",
description = "TPLink WiFi GUI"
),
)
)]
pub struct ApiDocs;
#[cfg(feature = "apidocs")]
impl ApiDocs {
pub fn generate() -> String {
ApiDocs::openapi().to_pretty_json().unwrap()
}
}

View File

@@ -1,30 +1,39 @@
mod analysis;
mod battery;
mod config;
mod crypto_provider;
mod diag;
mod display;
mod error;
mod gps;
mod key_input;
mod notifications;
mod pcap;
mod qmdl_store;
mod server;
mod stats;
mod update;
mod webdav;
use std::net::SocketAddr;
use std::sync::Arc;
use crate::battery::run_battery_notification_worker;
use crate::config::{parse_args, parse_config};
use crate::config::{GpsMode, parse_args, parse_config};
use crate::diag::run_diag_read_thread;
use crate::error::RayhunterError;
use crate::gps::{get_gps, post_gps};
use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config,
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
};
use crate::stats::{get_qmdl_manifest, get_system_stats};
use crate::stats::{get_qmdl_manifest, get_system_stats, get_update_status};
use crate::update::{UpdateStatus, run_update_check_worker};
use crate::webdav::run_webdav_upload_worker;
use wifi_station::WifiStatus;
use analysis::{
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
@@ -36,10 +45,9 @@ use diag::{
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
start_recording, stop_recording,
};
use log::{error, info};
use log::{error, info, warn};
use qmdl_store::RecordingStoreError;
use rayhunter::Device;
use rayhunter::diag_device::DiagDevice;
use stats::get_log;
use tokio::net::TcpListener;
use tokio::select;
@@ -57,6 +65,7 @@ fn get_router() -> AppRouter {
.route("/api/qmdl/{name}", get(get_qmdl))
.route("/api/zip/{name}", get(get_zip))
.route("/api/system-stats", get(get_system_stats))
.route("/api/update-status", get(get_update_status))
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/log", get(get_log))
.route("/api/start-recording", post(start_recording))
@@ -68,7 +77,14 @@ fn get_router() -> AppRouter {
.route("/api/analysis/{name}", post(start_analysis))
.route("/api/config", get(get_config))
.route("/api/config", post(set_config))
.route("/api/test-notification", post(test_notification))
.route("/api/wifi-status", get(get_wifi_status))
.route("/api/wifi-scan", post(scan_wifi))
.route("/api/time", get(get_time))
.route("/api/time-offset", post(set_time_offset))
.route("/api/debug/display-state", post(debug_set_display_state))
.route("/api/gps", get(get_gps))
.route("/api/gps", post(post_gps))
.route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static))
}
@@ -167,14 +183,9 @@ fn run_shutdown_thread(
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), RayhunterError> {
env_logger::init();
rayhunter::init_logging(log::LevelFilter::Info);
#[cfg(feature = "rustcrypto-tls")]
{
rustls_rustcrypto::provider()
.install_default()
.expect("Couldn't install rustcrypto provider");
}
crate::crypto_provider::install_default();
let args = parse_args();
@@ -203,22 +214,23 @@ async fn run_with_config(
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
let restart_token = CancellationToken::new();
let shutdown_token = restart_token.child_token();
// Ensure shutdown_token is cancelled when this function exits for any
// reason (e.g. diag device init failure), so all spawned tasks get
// signaled to stop.
let _shutdown_guard = shutdown_token.clone().drop_guard();
let notification_service = NotificationService::new(config.ntfy_url.clone());
let update_status_lock = Arc::new(RwLock::new(UpdateStatus::default()));
if !config.debug_mode {
info!("Using configuration for device: {0:?}", config.device);
let mut dev = DiagDevice::new(&config.device)
.await
.map_err(RayhunterError::DiagInitError)?;
dev.config_logs()
.await
.map_err(RayhunterError::DiagInitError)?;
info!("Starting Diag Thread");
let gps_fixed_coords = match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
(Some(lat), Some(lon)) => Some((lat, lon)),
_ => None,
};
run_diag_read_thread(
&task_tracker,
dev,
config.device.clone(),
diag_rx,
diag_tx.clone(),
ui_update_tx.clone(),
@@ -226,11 +238,15 @@ async fn run_with_config(
analysis_tx.clone(),
config.analyzers.clone(),
notification_service.new_handler(),
config.min_space_to_start_recording_mb,
config.min_space_to_continue_recording_mb,
config.gps_mode,
gps_fixed_coords,
);
info!("Starting UI");
let update_ui = match &config.device {
Device::Orbic => display::orbic::update_ui,
Device::Orbic | Device::Moxee => display::orbic::update_ui,
Device::Tplink => display::tplink::update_ui,
Device::Tmobile => display::tmobile::update_ui,
Device::Wingtech => display::wingtech::update_ui,
@@ -246,6 +262,16 @@ async fn run_with_config(
diag_tx.clone(),
shutdown_token.clone(),
);
if config.auto_check_updates {
run_update_check_worker(
&task_tracker,
shutdown_token.clone(),
update_status_lock.clone(),
notification_service.new_handler(),
config.enabled_notifications.clone(),
);
}
}
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
@@ -265,12 +291,14 @@ async fn run_with_config(
analysis_tx.clone(),
);
run_battery_notification_worker(
&task_tracker,
config.device.clone(),
notification_service.new_handler(),
shutdown_token.clone(),
);
if !config.debug_mode {
run_battery_notification_worker(
&task_tracker,
config.device.clone(),
notification_service.new_handler(),
shutdown_token.clone(),
);
}
run_notification_worker(
&task_tracker,
@@ -278,6 +306,41 @@ async fn run_with_config(
config.enabled_notifications.clone(),
);
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
if !config.debug_mode {
wifi_station::run_wifi_client(
&task_tracker,
&config.wifi_config(),
shutdown_token.clone(),
wifi_status.clone(),
);
}
if !config.webdav.url.trim().is_empty() {
run_webdav_upload_worker(
&task_tracker,
shutdown_token.clone(),
qmdl_store_lock.clone(),
config.webdav.clone().into(),
);
}
let initial_gps = if config.gps_mode == GpsMode::Fixed {
match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
(Some(lat), Some(lon)) => Some(gps::GpsData {
latitude: lat,
longitude: lon,
}),
_ => {
warn!(
"gps_mode is Fixed but gps_fixed_latitude or gps_fixed_longitude is missing from config — no GPS coordinates will be recorded"
);
None
}
}
} else {
None
};
let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
@@ -287,6 +350,10 @@ async fn run_with_config(
analysis_sender: analysis_tx,
daemon_restart_token: restart_token.clone(),
ui_update_sender: Some(ui_update_tx),
wifi_status,
wifi_scan_lock: tokio::sync::Mutex::new(()),
gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)),
update_status_lock: update_status_lock.clone(),
});
run_server(&task_tracker, state, shutdown_token.clone()).await;

View File

@@ -6,13 +6,27 @@ use std::{
use log::error;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::mpsc::{self, error::TryRecvError};
use tokio_util::task::TaskTracker;
pub const DEFAULT_NOTIFICATION_TIMEOUT: u64 = 10; //seconds
#[derive(Error, Debug)]
pub enum NotificationError {
#[error("HTTP request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Server returned error status: {0}")]
HttpError(reqwest::StatusCode),
}
/// Enum of valid notification types
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum NotificationType {
Warning,
LowBattery,
Update,
}
pub struct Notification {
@@ -45,6 +59,7 @@ struct NotificationStatus {
pub struct NotificationService {
url: Option<String>,
timeout: u64,
tx: mpsc::Sender<Notification>,
rx: mpsc::Receiver<Notification>,
}
@@ -52,7 +67,12 @@ pub struct NotificationService {
impl NotificationService {
pub fn new(url: Option<String>) -> Self {
let (tx, rx) = mpsc::channel(10);
Self { url, tx, rx }
Self {
url,
timeout: DEFAULT_NOTIFICATION_TIMEOUT,
tx,
rx,
}
}
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
@@ -60,6 +80,27 @@ impl NotificationService {
}
}
/// Sends a notification message to the specified URL.
pub async fn send_notification(
http_client: &reqwest::Client,
url: &str,
message: String,
timeout: u64,
) -> Result<(), NotificationError> {
let response = http_client
.post(url)
.body(message)
.timeout(Duration::from_secs(timeout))
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
Err(NotificationError::HttpError(response.status()))
}
}
pub fn run_notification_worker(
task_tracker: &TaskTracker,
mut notification_service: NotificationService,
@@ -125,24 +166,21 @@ pub fn run_notification_worker(
}
}
match http_client
.post(&url)
.body(notification.message.clone())
.send()
.await
match send_notification(
&http_client,
&url,
notification.message.clone(),
notification_service.timeout,
)
.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());
}
Ok(()) => {
notification.last_sent = Some(Instant::now());
notification.failed_since_last_success = 0;
notification.needs_sending = false;
}
Err(e) => {
error!("Failed to send notification to ntfy: {e}");
error!("Failed to send notification: {e}");
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
@@ -162,3 +200,243 @@ pub fn run_notification_worker(
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, body::Bytes, extract::State, routing::post};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[derive(Clone)]
struct TestServerState {
received_messages: Arc<Mutex<Vec<String>>>,
}
async fn capture_notification(
State(state): State<TestServerState>,
body: Bytes,
) -> &'static str {
let message = String::from_utf8_lossy(&body).to_string();
state.received_messages.lock().await.push(message);
"OK"
}
async fn setup_test_server() -> (Arc<Mutex<Vec<String>>>, String) {
crate::crypto_provider::install_default();
let received_messages = Arc::new(Mutex::new(Vec::new()));
let test_state = TestServerState {
received_messages: received_messages.clone(),
};
let app = Router::new()
.route("/", post(capture_notification))
.with_state(test_state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
(received_messages, url)
}
async fn setup_timeout_server(timeout: u64) -> String {
crate::crypto_provider::install_default();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move {
// Accept the connection but don't respond in the timeout
let (_socket, _addr) = listener.accept().await.unwrap();
tokio::time::sleep(Duration::from_secs(timeout * 2)).await;
});
tokio::time::sleep(Duration::from_millis(100)).await;
url
}
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
drop(sender);
tracker.close();
tracker.wait().await;
}
#[tokio::test]
async fn test_send_notification_times_out() {
let timeout: u64 = 2;
let url = setup_timeout_server(timeout).await;
let http_client = reqwest::Client::new();
let result = send_notification(
&http_client,
&url,
"test warning message".to_string(),
timeout,
)
.await;
match result {
Err(NotificationError::RequestFailed(reqwest_error)) => {
println!("error = {:?}", reqwest_error);
assert!(reqwest_error.is_timeout());
}
_ => unreachable!(),
}
}
#[tokio::test]
async fn test_notification_worker_sends_message() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning message".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "test warning message");
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_filters_disabled_types() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
notification_sender
.send(Notification::new(
NotificationType::LowBattery,
"test low battery".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "test warning");
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_sends_enabled_types() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning, NotificationType::LowBattery],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
notification_sender
.send(Notification::new(
NotificationType::LowBattery,
"test low battery".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 2);
// these are interchangeable, ordering not guaranteed
assert!(messages.contains(&"test warning".to_string()));
assert!(messages.contains(&"test low battery".to_string()));
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_with_no_url() {
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(None);
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(notification_sender, task_tracker).await;
}
}

View File

@@ -1,5 +1,8 @@
use crate::ServerState;
use crate::gps::{GpsRecord, load_gps_records};
use crate::qmdl_store::FileKind;
use crate::server::ServerState;
use crate::config::GpsMode;
use anyhow::Error;
use axum::body::Body;
use axum::extract::{Path, State};
@@ -9,15 +12,27 @@ use axum::response::{IntoResponse, Response};
use log::error;
use rayhunter::diag::DataType;
use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::pcap::{GpsPoint, GsmtapPcapWriter};
use rayhunter::qmdl::QmdlReader;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite, duplex};
use tokio_util::io::ReaderStream;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
// written so far. This is done by spawning a thread which streams chunks of
// pcap data to a channel that's piped to the client.
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/pcap/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "PCAP conversion successful", content_type = "application/vnd.tcpdump.pcap"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a PCAP file",
description = "Stream a PCAP file to a client in chunks by converting the QMDL data for file {name} written so far."
))]
pub async fn get_pcap(
State(state): State<Arc<ServerState>>,
Path(mut qmdl_name): Path<String>,
@@ -38,15 +53,16 @@ pub async fn get_pcap(
}
let qmdl_size_bytes = entry.qmdl_size_bytes;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.open_file(entry_index, FileKind::Qmdl)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
// the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
let (reader, writer) = duplex(1024);
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
drop(qmdl_store);
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, gps_records).await {
error!("failed to generate PCAP: {e:?}");
}
});
@@ -56,10 +72,68 @@ pub async fn get_pcap(
Ok((headers, body).into_response())
}
pub(crate) async fn load_gps_records_for_entry(
state: &Arc<ServerState>,
entry_index: usize,
) -> Vec<GpsRecord> {
let qmdl_store = state.qmdl_store_lock.read().await;
match qmdl_store.open_file(entry_index, FileKind::Gps).await {
Ok(Some(file)) => load_gps_records(file).await,
Ok(None) => {
let gps_mode = qmdl_store
.manifest
.entries
.get(entry_index)
.and_then(|e| e.gps_mode);
if gps_mode.is_some_and(|m| m != GpsMode::Disabled) {
error!(
"GPS storage expected for entry {entry_index} (mode: {gps_mode:?}) but not found"
);
}
vec![]
}
Err(e) => {
error!("failed to open GPS storage: {e}");
vec![]
}
}
}
fn record_timestamp(r: &GpsRecord) -> i64 {
r.latest_packet_timestamp.unwrap_or(i64::MIN)
}
fn find_nearest_gps(records: &[GpsRecord], packet_timestamp: i64) -> Option<GpsPoint> {
if records.is_empty() {
return None;
}
let idx = records.partition_point(|r| record_timestamp(r) <= packet_timestamp);
let record = if idx == 0 {
&records[0]
} else if idx >= records.len() {
&records[records.len() - 1]
} else {
let (before, after) = (&records[idx - 1], &records[idx]);
let before_delta = packet_timestamp - record_timestamp(before);
let after_delta = record_timestamp(after) - packet_timestamp;
if before_delta <= after_delta {
before
} else {
after
}
};
Some(GpsPoint {
latitude: record.lat,
longitude: record.lon,
unix_ts: record_timestamp(record),
})
}
pub async fn generate_pcap_data<R, W>(
writer: W,
qmdl_file: R,
qmdl_size_bytes: usize,
gps_records: Vec<GpsRecord>,
) -> Result<(), Error>
where
W: AsyncWrite + Unpin + Send,
@@ -74,13 +148,15 @@ where
continue;
}
for maybe_msg in container.into_messages() {
for maybe_msg in container.messages() {
match maybe_msg {
Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
let packet_unix_ts = timestamp.to_datetime().timestamp();
let gps = find_nearest_gps(&gps_records, packet_unix_ts);
pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp)
.write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref())
.await?;
}
}
@@ -91,3 +167,68 @@ where
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(latest_packet_timestamp: i64, lat: f64, lon: f64) -> GpsRecord {
GpsRecord {
latest_packet_timestamp: Some(latest_packet_timestamp),
system_time: 0,
lat,
lon,
}
}
#[test]
fn test_empty_returns_none() {
assert!(find_nearest_gps(&[], 100).is_none());
}
#[test]
fn test_single_record_always_returned() {
let records = vec![rec(100, 1.0, 2.0)];
assert_eq!(find_nearest_gps(&records, 0).unwrap().unix_ts, 100);
assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 100);
}
#[test]
fn test_before_all_records_returns_first() {
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
assert_eq!(find_nearest_gps(&records, 50).unwrap().unix_ts, 100);
}
#[test]
fn test_after_all_records_returns_last() {
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
assert_eq!(find_nearest_gps(&records, 300).unwrap().unix_ts, 200);
}
#[test]
fn test_exact_match() {
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0), rec(300, 5.0, 6.0)];
assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 200);
}
#[test]
fn test_closer_to_before() {
// packet at 130: delta to before(100)=30, delta to after(200)=70 → picks before
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
assert_eq!(find_nearest_gps(&records, 130).unwrap().unix_ts, 100);
}
#[test]
fn test_closer_to_after() {
// packet at 170: delta to before(100)=70, delta to after(200)=30 → picks after
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
assert_eq!(find_nearest_gps(&records, 170).unwrap().unix_ts, 200);
}
#[test]
fn test_equidistant_prefers_before() {
// packet at 150: delta to before(100)=50, delta to after(200)=50 → tie, picks before
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
assert_eq!(find_nearest_gps(&records, 150).unwrap().unix_ts, 100);
}
}

View File

@@ -1,8 +1,10 @@
use std::fmt::Display;
use std::io::{self, ErrorKind};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use crate::config::GpsMode;
use chrono::{DateTime, Local, TimeDelta};
use log::{info, warn};
use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize};
@@ -22,6 +24,8 @@ pub enum RecordingStoreError {
CreateFileError(tokio::io::Error),
#[error("Couldn't read file: {0}")]
ReadFileError(tokio::io::Error),
#[error("Couldn't write file: {0}")]
WriteFileError(tokio::io::Error),
#[error("Couldn't delete file: {0}")]
DeleteFileError(tokio::io::Error),
#[error("Couldn't open directory at path: {0}")]
@@ -32,6 +36,46 @@ pub enum RecordingStoreError {
WriteManifestError(tokio::io::Error),
#[error("Couldn't parse QMDL store manifest file: {0}")]
ParseManifestError(toml::de::Error),
#[error("Insufficient disk space: {0}MB available, {1}MB required")]
InsufficientDiskSpace(u64, u64),
#[error("GPS storage directory not found")]
GpsStorageNotFound,
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
Qmdl,
Analysis,
Gps,
}
impl FileKind {
// List of all possible physical files on disk.
pub const ALL: &'static [FileKind] = &[FileKind::Qmdl, FileKind::Analysis, FileKind::Gps];
pub fn get_filename(&self, entry_name: &str) -> String {
match self {
FileKind::Qmdl => format!("{}.qmdl", entry_name),
FileKind::Analysis => format!("{}.ndjson", entry_name),
FileKind::Gps => format!("{}-gps.ndjson", entry_name),
}
}
pub fn get_filepath<P: AsRef<Path>>(&self, entry_name: &str, base_path: P) -> PathBuf {
base_path.as_ref().join(self.get_filename(entry_name))
}
}
impl Display for FileKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileKind::Qmdl => write!(f, "QMDL"),
FileKind::Analysis => write!(f, "analysis"),
FileKind::Gps => write!(f, "GPS"),
}
}
}
pub struct RecordingStore {
@@ -45,20 +89,38 @@ pub struct Manifest {
pub entries: Vec<ManifestEntry>,
}
/// The structure of an entry in the QMDL manifest table
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct ManifestEntry {
/// The name of the entry
pub name: String,
/// The system time when recording began
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub start_time: DateTime<Local>,
/// The system time when the last message was recorded to the file
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub last_message_time: Option<DateTime<Local>>,
/// The size of the QMDL file in bytes
pub qmdl_size_bytes: usize,
/// The rayhunter daemon version which generated the file
pub rayhunter_version: Option<String>,
/// The OS which created the file
pub system_os: Option<String>,
/// The architecture on which the OS was running
pub arch: Option<String>,
#[serde(default)]
pub stop_reason: Option<String>,
/// When the manifest was uploaded to a WebDAV server
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub upload_time: Option<DateTime<Local>>,
#[serde(default)]
pub gps_mode: Option<GpsMode>,
}
impl ManifestEntry {
fn new() -> Self {
let now = Local::now();
fn new(gps_mode: GpsMode) -> Self {
let now = rayhunter::clock::get_adjusted_now();
let metadata = RuntimeMetadata::new();
ManifestEntry {
name: format!("{}", now.timestamp()),
@@ -68,19 +130,14 @@ impl ManifestEntry {
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
stop_reason: None,
upload_time: None,
gps_mode: Some(gps_mode),
}
}
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name);
filepath.set_extension("qmdl");
filepath
}
pub fn get_analysis_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name);
filepath.set_extension("ndjson");
filepath
pub fn get_filepath<P: AsRef<Path>>(&self, file_kind: FileKind, path: P) -> PathBuf {
file_kind.get_filepath(&self.name, path)
}
}
@@ -197,11 +254,14 @@ impl RecordingStore {
rayhunter_version: None,
system_os: None,
arch: None,
stop_reason: None,
upload_time: None,
gps_mode: None,
});
}
// sort chronologically
manifest_entries.sort_by(|a, b| a.start_time.cmp(&b.start_time));
manifest_entries.sort_by_key(|a| a.start_time);
let mut store = RecordingStore {
path: path.as_ref().to_path_buf(),
@@ -229,43 +289,63 @@ impl RecordingStore {
// Closes the current entry (if needed), creates a new entry based on the
// current time, and updates the manifest. Returns a tuple of the entry's
// newly created QMDL file and analysis file.
pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> {
pub async fn new_entry(
&mut self,
gps_mode: GpsMode,
) -> Result<(File, File), RecordingStoreError> {
// if we've already got an entry open, close it
if self.current_entry.is_some() {
self.close_current_entry().await?;
}
let new_entry = ManifestEntry::new();
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
let new_entry = ManifestEntry::new(gps_mode);
let qmdl_filepath = new_entry.get_filepath(FileKind::Qmdl, &self.path);
let qmdl_file = File::create(&qmdl_filepath)
.await
.map_err(RecordingStoreError::CreateFileError)?;
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
let analysis_filepath = new_entry.get_filepath(FileKind::Analysis, &self.path);
let analysis_file = File::create(&analysis_filepath)
.await
.map_err(RecordingStoreError::CreateFileError)?;
let gps_filepath = new_entry.get_filepath(FileKind::Gps, &self.path);
File::create(&gps_filepath)
.await
.map_err(RecordingStoreError::CreateFileError)?;
self.manifest.entries.push(new_entry);
self.current_entry = Some(self.manifest.entries.len() - 1);
self.write_manifest().await?;
Ok((qmdl_file, analysis_file))
}
// Returns the corresponding QMDL file for a given entry
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index];
File::open(entry.get_qmdl_filepath(&self.path))
.await
.map_err(RecordingStoreError::ReadFileError)
}
// Returns the corresponding QMDL file for a given entry
pub async fn open_entry_analysis(
pub async fn open_file(
&self,
entry_index: usize,
) -> Result<File, RecordingStoreError> {
file_kind: FileKind,
) -> Result<Option<File>, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index];
File::open(entry.get_analysis_filepath(&self.path))
let filepath = file_kind.get_filepath(&entry.name, &self.path);
match File::open(&filepath).await {
Ok(file) => Ok(Some(file)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
Err(e) => Err(RecordingStoreError::ReadFileError(e)),
}
}
pub async fn open_entry_gps_for_append(
&self,
entry_index: usize,
) -> Result<Option<File>, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index];
match OpenOptions::new()
.create(true)
.append(true)
.open(entry.get_filepath(FileKind::Gps, &self.path))
.await
.map_err(RecordingStoreError::ReadFileError)
{
Ok(file) => Ok(Some(file)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
Err(e) => Err(RecordingStoreError::CreateFileError(e)),
}
}
pub async fn clear_and_open_entry_analysis(
@@ -276,7 +356,7 @@ impl RecordingStore {
let file = OpenOptions::new()
.write(true)
.truncate(true)
.open(entry.get_analysis_filepath(&self.path))
.open(entry.get_filepath(FileKind::Analysis, &self.path))
.await
.map_err(RecordingStoreError::ReadFileError)?;
Ok(file)
@@ -300,7 +380,8 @@ impl RecordingStore {
size_bytes: usize,
) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
self.manifest.entries[entry_index].last_message_time =
Some(rayhunter::clock::get_adjusted_now());
self.write_manifest().await
}
@@ -326,6 +407,23 @@ impl RecordingStore {
Ok(())
}
pub fn get_next_unuploaded_entry(&self, min_age: TimeDelta) -> Option<String> {
let now = rayhunter::clock::get_adjusted_now();
self.manifest
.entries
.iter()
.filter_map(|entry| {
if self.is_current_entry(&entry.name) || entry.upload_time.is_some() {
return None;
}
let age = now - entry.last_message_time.unwrap_or(entry.start_time);
(age > min_age).then_some((&entry.name, age))
})
.max_by_key(|(_, age)| *age)
.map(|(name, _)| name.clone())
}
// Finds an entry by filename
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
let entry_index = self
@@ -341,6 +439,33 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index]))
}
pub async fn set_current_stop_reason(
&mut self,
reason: String,
) -> Result<(), RecordingStoreError> {
if let Some(idx) = self.current_entry {
self.manifest.entries[idx].stop_reason = Some(reason);
self.write_manifest().await?;
}
Ok(())
}
pub async fn mark_entry_as_uploaded(
&mut self,
name: &str,
upload_time: DateTime<Local>,
) -> Result<(), RecordingStoreError> {
let entry_index = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?;
self.manifest.entries[entry_index].upload_time = Some(upload_time);
self.write_manifest().await?;
Ok(())
}
pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) {
@@ -369,14 +494,13 @@ impl RecordingStore {
};
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?;
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
remove_file_if_exists(&qmdl_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
remove_file_if_exists(&analysis_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
for &file_kind in FileKind::ALL {
let filepath = file_kind.get_filepath(&entry_to_delete.name, &self.path);
remove_file_if_exists(&filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
}
Ok(())
}
@@ -387,20 +511,16 @@ impl RecordingStore {
let mut keep = Vec::new();
for entry in &self.manifest.entries {
let qmdl_filepath = entry.get_qmdl_filepath(&self.path);
let analysis_filepath = entry.get_analysis_filepath(&self.path);
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await {
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}");
keep.push(true);
continue;
}
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
keep.push(true);
continue;
'entries: for entry in &self.manifest.entries {
for &file_kind in FileKind::ALL {
let filepath = file_kind.get_filepath(&entry.name, &self.path);
if let Err(e) = remove_file_if_exists(&filepath).await {
log::warn!("failed to remove {filepath:?}: {e:?}");
// Some error happened with deleting this entry, abort and go to the next one.
// Also *keep* the manifest entry.
keep.push(true);
continue 'entries;
}
}
keep.push(false);
@@ -443,7 +563,7 @@ mod tests {
async fn test_creating_updating_and_closing_entries() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
let entry_index = store.current_entry.unwrap();
assert_eq!(
RecordingStore::read_manifest(dir.path()).await.unwrap(),
@@ -480,7 +600,7 @@ mod tests {
async fn test_create_on_existing_store() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
let entry_index = store.current_entry.unwrap();
store
.update_entry_qmdl_size(entry_index, 1000)
@@ -494,9 +614,9 @@ mod tests {
async fn test_repeated_new_entries() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
let entry_index = store.current_entry.unwrap();
let _ = store.new_entry().await.unwrap();
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
let new_entry_index = store.current_entry.unwrap();
assert_ne!(entry_index, new_entry_index);
assert_eq!(store.manifest.entries.len(), 2);
@@ -506,7 +626,7 @@ mod tests {
async fn test_delete_all_entries() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
assert!(store.current_entry.is_some());
store.delete_all_entries().await.unwrap();
@@ -517,4 +637,78 @@ mod tests {
store.delete_all_entries().await.unwrap();
assert!(store.current_entry.is_none());
}
#[tokio::test]
async fn test_mark_entry_as_uploaded_sets_time_and_persists() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
let name = store.manifest.entries[0].name.clone();
store.close_current_entry().await.unwrap();
let upload_time = Local::now();
store
.mark_entry_as_uploaded(&name, upload_time)
.await
.unwrap();
assert_eq!(store.manifest.entries[0].upload_time, Some(upload_time));
let reloaded = RecordingStore::load(dir.path()).await.unwrap();
assert_eq!(reloaded.manifest.entries[0].upload_time, Some(upload_time));
}
#[tokio::test]
async fn test_mark_entry_as_uploaded_missing_entry() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
assert!(matches!(
store.mark_entry_as_uploaded("nope", Local::now()).await,
Err(RecordingStoreError::NoSuchEntryError)
));
}
#[tokio::test]
async fn test_get_next_unuploaded_entry() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
for _ in 0..3 {
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
}
store.manifest.entries[0].name = "entry-0".to_owned();
store.manifest.entries[0].start_time = Local::now() - TimeDelta::seconds(10);
store.manifest.entries[0].last_message_time = None;
store.manifest.entries[1].name = "entry-1".to_owned();
store.manifest.entries[1].start_time = Local::now() - TimeDelta::seconds(10);
store.manifest.entries[1].last_message_time = Some(Local::now() - TimeDelta::seconds(5));
store.manifest.entries[2].name = "entry-2".to_owned();
store.manifest.entries[2].start_time = Local::now() - TimeDelta::seconds(10);
store.manifest.entries[2].last_message_time = Some(Local::now() - TimeDelta::seconds(1));
assert_eq!(
store.get_next_unuploaded_entry(TimeDelta::seconds(3600)),
None,
);
assert_eq!(
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
Some("entry-0".to_owned())
);
store
.mark_entry_as_uploaded("entry-0", Local::now())
.await
.unwrap();
assert_eq!(
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
Some("entry-1".to_owned())
);
store
.mark_entry_as_uploaded("entry-1", Local::now())
.await
.unwrap();
assert_eq!(store.get_next_unuploaded_entry(TimeDelta::seconds(3)), None);
}
}

View File

@@ -9,7 +9,9 @@ use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Local};
use log::{error, warn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::fs::write;
use tokio::io::{AsyncReadExt, copy, duplex};
@@ -19,12 +21,15 @@ use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream;
use tokio_util::sync::CancellationToken;
use crate::DiagDeviceCtrlMessage;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config;
use crate::config::{Config, GpsMode};
use crate::diag::DiagDeviceCtrlMessage;
use crate::display::DisplayState;
use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore;
use crate::gps::GpsData;
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
use crate::pcap::{generate_pcap_data, load_gps_records_for_entry};
use crate::qmdl_store::{FileKind, RecordingStore};
use crate::update::UpdateStatus;
pub struct ServerState {
pub config_path: String,
@@ -35,8 +40,27 @@ pub struct ServerState {
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_token: CancellationToken,
pub ui_update_sender: Option<Sender<DisplayState>>,
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
pub wifi_scan_lock: tokio::sync::Mutex<()>,
pub gps_state: Arc<RwLock<Option<GpsData>>>,
pub update_status_lock: Arc<RwLock<UpdateStatus>>,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/qmdl/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "QMDL download successful", content_type = "application/octet-stream"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a QMDL file",
description = "Stream the QMDL file {name} to the client."
))]
pub async fn get_qmdl(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
@@ -48,14 +72,15 @@ pub async fn get_qmdl(
format!("couldn't find qmdl file with name {qmdl_idx}"),
))?;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.open_file(entry_index, FileKind::Qmdl)
.await
.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {err}"),
)
})?;
})?
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
@@ -104,17 +129,54 @@ pub async fn serve_static(
}
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/config",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = Config)
),
summary = "Get config",
description = "Show the running configuration for Rayhunter."
))]
pub async fn get_config(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Config>, (StatusCode, String)> {
Ok(Json(state.config.clone()))
let mut config = state.config.clone();
config.wifi_password = None;
Ok(Json(config))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/config",
tag = "Configuration",
request_body(
content = Option<[Config]>,
description = "Any or all configuration elements from the valid config schema to be altered may be passed. Invalid keys will be discarded. Invalid values or value types will return an error."
),
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to parse or write config file"),
(status = 422, description = "Failed to deserialize JSON body")
),
summary = "Set config",
description = "Write a new configuration for Rayhunter and trigger a restart."
))]
pub async fn set_config(
State(state): State<Arc<ServerState>>,
Json(config): Json<Config>,
Json(mut config): Json<Config>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let config_str = toml::to_string_pretty(&config).map_err(|err| {
if config.gps_mode != GpsMode::Fixed {
config.gps_fixed_latitude = None;
config.gps_fixed_longitude = None;
}
let mut config_to_write = config.clone();
config_to_write.wifi_ssid = None;
config_to_write.wifi_password = None;
config_to_write.wifi_security = None;
let config_str = toml::to_string_pretty(&config_to_write).map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to serialize config as TOML: {err}"),
@@ -128,6 +190,8 @@ pub async fn set_config(
)
})?;
wifi_station::update_wpa_conf(&config.wifi_config()).await;
// Trigger daemon restart after writing config
state.daemon_restart_token.cancel();
Ok((
@@ -136,6 +200,135 @@ pub async fn set_config(
))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/test-notification",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success"),
(status = StatusCode::BAD_REQUEST, description = "No notification URL set"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send HTTP request. Ensure your device can reach the internet.")
),
summary = "Test ntfy notification",
description = "Send a test notification to the ntfy_url in the running configuration for Rayhunter."
))]
pub async fn test_notification(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let url = state.config.ntfy_url.as_ref().ok_or((
StatusCode::BAD_REQUEST,
"No notification URL configured".to_string(),
))?;
if url.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Notification URL is empty".to_string(),
));
}
let http_client = reqwest::Client::new();
let message = "Test notification from Rayhunter".to_string();
crate::notifications::send_notification(
&http_client,
url,
message,
DEFAULT_NOTIFICATION_TIMEOUT,
)
.await
.map(|()| {
(
StatusCode::OK,
"Test notification sent successfully".to_string(),
)
})
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to send test notification: {e}"),
)
})
}
/// Response for GET /api/time
#[derive(Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct TimeResponse {
/// The raw system time (without clock offset)
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub system_time: DateTime<Local>,
/// The adjusted time (system time + offset)
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub adjusted_time: DateTime<Local>,
/// The current offset in seconds
pub offset_seconds: i64,
}
/// Request for POST /api/time-offset
#[derive(Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct SetTimeOffsetRequest {
/// The offset to set, in seconds
pub offset_seconds: i64,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/time",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = TimeResponse)
),
summary = "Get time",
description = "Get the current time and offset (in seconds) of the device."
))]
pub async fn get_time() -> Json<TimeResponse> {
let system_time = Local::now();
let adjusted_time = rayhunter::clock::get_adjusted_now();
let offset_seconds = adjusted_time
.signed_duration_since(system_time)
.num_seconds();
Json(TimeResponse {
system_time,
adjusted_time,
offset_seconds,
})
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/time-offset",
tag = "Configuration",
request_body(
content = SetTimeOffsetRequest
),
responses(
(status = StatusCode::OK, description = "Success", body = TimeResponse)
),
summary = "Set time offset",
description = "Set the difference (in seconds) between the system time and the adjusted time for Rayhunter."
))]
pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode {
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
StatusCode::OK
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/zip/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "ZIP download successful. It is possible that if the PCAP fails to convert, the same status will be returned, but the file will contain only the QMDL file.", content_type = "application/zip"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a ZIP file",
description = "Stream a ZIP file to the client which contains the QMDL file {name} and a PCAP generated from the same file."
))]
pub async fn get_zip(
State(state): State<Arc<ServerState>>,
Path(entry_name): Path<String>,
@@ -159,6 +352,7 @@ pub async fn get_zip(
};
let qmdl_store_lock = state.qmdl_store_lock.clone();
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
let (reader, writer) = duplex(8192);
@@ -166,24 +360,40 @@ pub async fn get_zip(
let result: Result<(), Error> = async {
let mut zip = ZipFileWriter::with_tokio(writer);
// Add QMDL file
{
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
const EXCLUDED_FROM_ZIP: &[FileKind] = &[FileKind::Analysis];
// Add stored files
for &file_kind in FileKind::ALL {
if EXCLUDED_FROM_ZIP.contains(&file_kind) {
continue;
}
let file_opt = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store.open_file(entry_index, file_kind).await?
};
let Some(mut file) = file_opt else {
continue;
};
let entry = ZipEntryBuilder::new(
file_kind.get_filename(&qmdl_idx).into(),
Compression::Stored,
);
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let mut qmdl_file = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
// Truncating to qmdl_size_bytes is an attempt to ignore partial writes by the diag
// thread.
if file_kind == FileKind::Qmdl {
copy(&mut file.take(qmdl_size_bytes as u64), &mut entry_writer).await?;
} else {
copy(&mut file, &mut entry_writer).await?;
}
copy(&mut qmdl_file, &mut entry_writer).await?;
entry_writer.into_inner().close().await?;
}
@@ -196,13 +406,19 @@ pub async fn get_zip(
let qmdl_file_for_pcap = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.open_file(entry_index, FileKind::Qmdl)
.await?
.ok_or_else(|| anyhow::anyhow!("QMDL file not found"))?
.take(qmdl_size_bytes as u64)
};
if let Err(e) =
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
if let Err(e) = generate_pcap_data(
&mut entry_writer,
qmdl_file_for_pcap,
qmdl_size_bytes,
gps_records,
)
.await
{
// if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL.
@@ -227,6 +443,70 @@ pub async fn get_zip(
Ok((headers, body).into_response())
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/wifi-status",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = wifi_station::WifiStatus)
),
summary = "Get wifi status",
description = "Show the status of the wifi client."
))]
pub async fn get_wifi_status(
State(state): State<Arc<ServerState>>,
) -> Json<wifi_station::WifiStatus> {
let status = state.wifi_status.read().await;
Json(status.clone())
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/wifi-scan",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Scan success", body = inline(Vec<wifi_station::WifiNetwork>), content_type = "application/json"),
(status = StatusCode::TOO_MANY_REQUESTS, description = "Scan already in progress"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Scan failed"),
),
summary = "Wifi SSID scan",
description = "Poll for a list of available wifi networks. Returns an array of WifiNetwork objects."
))]
pub async fn scan_wifi(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Vec<wifi_station::WifiNetwork>>, (StatusCode, String)> {
let _guard = state.wifi_scan_lock.try_lock().map_err(|_| {
(
StatusCode::TOO_MANY_REQUESTS,
"WiFi scan already in progress".to_string(),
)
})?;
let networks = wifi_station::scan_wifi_networks(wifi_station::STA_IFACE)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("WiFi scan failed: {e}"),
)
})?;
Ok(Json(networks))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/debug/display-state",
tag = "Configuration",
request_body(
content = DisplayState
),
responses(
(status = StatusCode::OK, description = "Display state updated successfully"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error sending update to the display"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "Display system not available")
),
summary = "Set display state",
description = "Change the display state (color bar or otherwise) of the device for debugging purposes."
))]
pub async fn debug_set_display_state(
State(state): State<Arc<ServerState>>,
Json(display_state): Json<DisplayState>,
@@ -253,6 +533,7 @@ pub async fn debug_set_display_state(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GpsMode;
use async_zip::base::read::mem::ZipFileReader;
use axum::extract::{Path, State};
use tempfile::TempDir;
@@ -272,7 +553,7 @@ mod tests {
) -> String {
let entry_name = {
let mut store = store_lock.write().await;
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
let (mut qmdl_file, _analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap();
if !test_data.is_empty() {
use tokio::io::AsyncWriteExt;
@@ -316,6 +597,10 @@ mod tests {
analysis_sender: analysis_tx,
daemon_restart_token: CancellationToken::new(),
ui_update_sender: None,
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
wifi_scan_lock: tokio::sync::Mutex::new(()),
gps_state: Arc::new(RwLock::new(None)),
update_status_lock: Arc::new(RwLock::new(UpdateStatus::default())),
})
}
@@ -348,7 +633,11 @@ mod tests {
assert_eq!(
filenames,
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
vec![
format!("{entry_name}.qmdl"),
format!("{entry_name}-gps.ndjson"),
format!("{entry_name}.pcapng"),
]
);
}
}

View File

@@ -1,8 +1,10 @@
use std::ffi::CString;
use std::sync::Arc;
use crate::battery::get_battery_status;
use crate::error::RayhunterError;
use crate::server::ServerState;
use crate::update::UpdateStatus;
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
use axum::Json;
@@ -13,7 +15,9 @@ use rayhunter::{Device, util::RuntimeMetadata};
use serde::Serialize;
use tokio::process::Command;
/// Structure of device system statistics
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct SystemStats {
pub disk_stats: DiskStats,
pub memory_stats: MemoryStats,
@@ -25,7 +29,7 @@ pub struct SystemStats {
impl SystemStats {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
Ok(Self {
disk_stats: DiskStats::new(qmdl_path, device).await?,
disk_stats: DiskStats::new(qmdl_path)?,
memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await {
@@ -40,49 +44,72 @@ impl SystemStats {
}
}
/// Device storage information
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct DiskStats {
/// The partition to which the daemon is installed
partition: String,
/// The total disk size of the partition
total_size: String,
/// Total used size of the partition
used_size: String,
/// Remaining free space of the partition
available_size: String,
/// Disk usage displayed as percentage
used_percent: String,
/// The root folder to which the partition is mounted
mounted_on: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub available_bytes: Option<u64>,
}
impl DiskStats {
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
// the QMDL file.
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
// Uz801 needs to be told to use the busybox df specifically
let mut df_cmd: Command;
if matches!(device, Device::Uz801) {
df_cmd = Command::new("busybox");
df_cmd.arg("df");
} else {
df_cmd = Command::new("df");
#[allow(clippy::unnecessary_cast)] // c_ulong is u32 on ARM, u64 on macOS
pub fn new(qmdl_path: &str) -> Result<Self, String> {
let c_path =
CString::new(qmdl_path).map_err(|e| format!("invalid path {qmdl_path}: {e}"))?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
return Err(format!(
"statvfs({qmdl_path}) failed: {}",
std::io::Error::last_os_error()
));
}
df_cmd.arg("-h");
df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?;
// Handle standard df -h format
let mut parts = stdout.split_whitespace().skip(7);
let block_size = stat.f_frsize as u64;
let total_kb = (stat.f_blocks as u64 * block_size / 1024) as usize;
let free_kb = (stat.f_bfree as u64 * block_size / 1024) as usize;
let available_kb = (stat.f_bavail as u64 * block_size / 1024) as usize;
let used_kb = total_kb.saturating_sub(free_kb);
let used_percent = format!(
"{}%",
((stat.f_blocks - stat.f_bfree) * 100)
.checked_div(stat.f_blocks)
.unwrap_or(0)
);
Ok(Self {
partition: parts.next().ok_or("error parsing df output")?.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
used_size: parts.next().ok_or("error parsing df output")?.to_string(),
available_size: parts.next().ok_or("error parsing df output")?.to_string(),
used_percent: parts.next().ok_or("error parsing df output")?.to_string(),
mounted_on: parts.next().ok_or("error parsing df output")?.to_string(),
partition: qmdl_path.to_string(),
total_size: humanize_kb(total_kb),
used_size: humanize_kb(used_kb),
available_size: humanize_kb(available_kb),
used_percent,
mounted_on: qmdl_path.to_string(),
available_bytes: Some(stat.f_bavail as u64 * block_size),
})
}
}
/// Device memory information
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct MemoryStats {
/// The total memory available on the device
total: String,
/// The currently used memory
used: String,
/// Remaining free memory
free: String,
}
@@ -135,6 +162,17 @@ fn humanize_kb(kb: usize) -> String {
format!("{:.1}M", kb as f64 / 1024.0)
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/system-stats",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", body = SystemStats),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error collecting statistics")
),
summary = "Get system info",
description = "Display system/device statistics."
))]
pub async fn get_system_stats(
State(state): State<Arc<ServerState>>,
) -> Result<Json<SystemStats>, (StatusCode, String)> {
@@ -151,12 +189,26 @@ pub async fn get_system_stats(
}
}
/// QMDL manifest information
#[derive(Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct ManifestStats {
/// A vector containing the names of the QMDL files
pub entries: Vec<ManifestEntry>,
/// The currently open QMDL file
pub current_entry: Option<ManifestEntry>,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/qmdl-manifest",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", body = ManifestStats)
),
summary = "QMDL Manifest",
description = "List QMDL files available on the device and some of their basic statistics."
))]
pub async fn get_qmdl_manifest(
State(state): State<Arc<ServerState>>,
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
@@ -169,6 +221,31 @@ pub async fn get_qmdl_manifest(
}))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/update-status",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", body = UpdateStatus)
),
summary = "Rayhunter update status",
description = "Check for available updates for Rayhunter."
))]
pub async fn get_update_status(State(state): State<Arc<ServerState>>) -> Json<UpdateStatus> {
Json(state.update_status_lock.read().await.clone())
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/log",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", content_type = "text/plain"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Could not read /data/rayhunter/rayhunter.log file")
),
summary = "Display log",
description = "Download the current device log in UTF-8 plaintext."
))]
pub async fn get_log() -> Result<String, (StatusCode, String)> {
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
.await

274
daemon/src/update.rs Normal file
View File

@@ -0,0 +1,274 @@
use chrono::{DateTime, Local};
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::select;
use tokio::sync::{RwLock, mpsc::Sender};
use tokio::time;
use tokio::time::{Duration, MissedTickBehavior};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::notifications::{Notification, NotificationType};
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
const GITHUB_LATEST_RELEASE_URL: &str =
"https://api.github.com/repos/EFForg/rayhunter/releases/latest";
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct UpdateStatus {
pub current_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_release_url: Option<String>,
pub update_available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "apidocs", schema(value_type = Option<String>, format = "date-time"))]
pub last_checked: Option<DateTime<Local>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
impl Default for UpdateStatus {
fn default() -> Self {
Self {
current_version: get_current_version(),
// To-be-populated by update check worker
latest_version: None,
latest_release_url: None,
update_available: false,
last_checked: None,
last_error: None,
}
}
}
#[derive(Debug, Deserialize)]
struct GitHubReleaseResponse {
tag_name: String,
html_url: String,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
struct VersionParts {
major: u64,
minor: u64,
patch: u64,
}
fn get_current_version() -> String {
// See https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
env!("CARGO_PKG_VERSION").to_owned()
}
fn parse_release_tagname(version: &str) -> Option<(VersionParts, String)> {
// Trim whitespace and leading `v`, if any
let trimmed_version = version.trim().trim_start_matches('v');
let mut parts = trimmed_version.split('.');
// Fail on versions with pre-release metadata: https://github.com/EFForg/rayhunter/pull/1054#issuecomment-4528407281
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch = parts.next()?.parse::<u64>().ok()?;
// Expect only major.minor.patch format
if parts.next().is_some() {
return None;
}
let version = format!("{}.{}.{}", major, minor, patch);
Some((
VersionParts {
major,
minor,
patch,
},
version.to_string(),
))
}
fn format_update_message(current_version: &str, latest_version: &str, release_url: &str) -> String {
format!(
"Rayhunter {current_version} is installed, but {latest_version} is available. Open {release_url} to download the update."
)
}
async fn refresh_update_status(
status_lock: &Arc<RwLock<UpdateStatus>>,
http_client: &reqwest::Client,
) -> Result<Option<(String, String)>, String> {
let response = http_client
.get(GITHUB_LATEST_RELEASE_URL)
.timeout(Duration::from_secs(5))
.header(reqwest::header::USER_AGENT, "rayhunter-update-checker")
.send()
.await
.map_err(|err| format!("failed to query GitHub releases: {err}"))?;
if !response.status().is_success() {
return Err(format!(
"GitHub release check returned {}",
response.status()
));
}
let response_text = response
.text()
.await
.map_err(|err| format!("failed to read GitHub release response: {err}"))?;
let release: GitHubReleaseResponse = serde_json::from_str(&response_text)
.map_err(|err| format!("failed to parse GitHub release response: {err}"))?;
let current_version = get_current_version();
let (current_version_parts, current_version) = parse_release_tagname(&current_version)
.ok_or_else(|| format!("failed to parse current version {current_version}"))?;
let (latest_version_parts, latest_version) = parse_release_tagname(&release.tag_name)
.ok_or_else(|| {
format!(
"failed to parse latest release version {}",
release.tag_name
)
})?;
let update_available = latest_version_parts > current_version_parts;
{
let mut status = status_lock.write().await;
status.current_version = current_version;
status.latest_version = Some(latest_version.to_owned());
status.latest_release_url = Some(release.html_url.to_owned());
status.update_available = update_available;
status.last_checked = Some(Local::now());
status.last_error = None;
}
if update_available {
Ok(Some((latest_version, release.html_url)))
} else {
Ok(None)
}
}
pub fn run_update_check_worker(
task_tracker: &TaskTracker,
shutdown_token: CancellationToken,
update_status_lock: Arc<RwLock<UpdateStatus>>,
notification_sender: Sender<Notification>,
enabled_notifications: Vec<NotificationType>,
) {
task_tracker.spawn(async move {
let http_client = match reqwest::Client::builder().build() {
Ok(client) => client,
Err(err) => {
error!("failed to create update check client: {err}");
return;
}
};
let mut interval = time::interval(UPDATE_CHECK_INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
// Keep track of last notified version
let mut last_notified_version: Option<String> = None;
loop {
if shutdown_token.is_cancelled() {
break;
}
match refresh_update_status(&update_status_lock, &http_client).await {
Ok(Some((latest_version, latest_release_url))) => {
if last_notified_version.as_deref() != Some(latest_version.as_str()) {
let current_version =
update_status_lock.read().await.current_version.clone();
let message = format_update_message(
&current_version,
&latest_version,
&latest_release_url,
);
if enabled_notifications.contains(&NotificationType::Update) {
if let Err(err) = notification_sender
.send(Notification::new(NotificationType::Update, message, None))
.await
{
error!("failed to enqueue update notification: {err}");
} else {
info!("notified about Rayhunter update {latest_version}");
}
}
last_notified_version = Some(latest_version);
}
}
Ok(None) => {
last_notified_version = None;
}
Err(err) => {
warn!("update check failed: {err}");
let mut status = update_status_lock.write().await;
status.last_error = Some(err);
status.last_checked = Some(Local::now());
}
}
select! {
_ = shutdown_token.cancelled() => break,
_ = interval.tick() => {}
}
}
});
}
#[cfg(test)]
mod tests {
use super::parse_release_tagname;
#[test]
fn parses_simple_versions() {
let (parts, version) = parse_release_tagname("0.11.1").unwrap();
assert_eq!(parts.major, 0);
assert_eq!(parts.minor, 11);
assert_eq!(parts.patch, 1);
assert_eq!(version, "0.11.1");
}
#[test]
fn returns_none_for_invalid_versions() {
assert!(parse_release_tagname("invalid").is_none());
assert!(parse_release_tagname("v1.2").is_none());
assert!(parse_release_tagname("v1.2.x").is_none());
assert!(parse_release_tagname("v1.2.3.4").is_none());
assert!(parse_release_tagname("v1.2.-3").is_none());
assert!(parse_release_tagname("v1.2.3-beta").is_none());
assert!(parse_release_tagname("v1.2.3-beta.1").is_none());
assert!(parse_release_tagname("1.2").is_none());
assert!(parse_release_tagname("1.2.x").is_none());
assert!(parse_release_tagname("1.2.3.4").is_none());
assert!(parse_release_tagname("1.2.-3").is_none());
assert!(parse_release_tagname("1.2.3-beta").is_none());
assert!(parse_release_tagname("1.2.3-beta.1").is_none());
}
#[test]
fn compares_versions_numerically() {
let (newer_version_parts, newer_version) = parse_release_tagname("v0.11.2").unwrap();
let (older_version_parts, older_version) = parse_release_tagname("v0.11.1").unwrap();
assert!(newer_version_parts > older_version_parts);
assert_eq!(newer_version, "0.11.2");
assert_eq!(older_version, "0.11.1");
}
#[test]
fn compares_major_minor_patch_correctly() {
let (v1_parts, v1) = parse_release_tagname("v1.0.0").unwrap();
let (v2_parts, v2) = parse_release_tagname("v1.0.1").unwrap();
let (v3_parts, v3) = parse_release_tagname("v1.1.0").unwrap();
let (v4_parts, v4) = parse_release_tagname("v2.0.0").unwrap();
assert!(v2_parts > v1_parts);
assert!(v3_parts > v2_parts);
assert!(v4_parts > v3_parts);
assert_eq!(v1, "1.0.0");
assert_eq!(v2, "1.0.1");
assert_eq!(v3, "1.1.0");
assert_eq!(v4, "2.0.0");
}
}

424
daemon/src/webdav.rs Normal file
View File

@@ -0,0 +1,424 @@
use std::{sync::Arc, time::Duration};
use chrono::TimeDelta;
use futures::future::join_all;
use log::{info, warn};
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
use reqwest::{Body, Client, Response};
use tokio::fs::File;
use tokio::{select, sync::RwLock, time};
use tokio_util::io::ReaderStream;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::config::WebdavConfig;
use crate::qmdl_store::{FileKind, RecordingStore};
pub struct WebdavUploadWorkerConfig {
poll_interval: Duration,
min_age: TimeDelta,
url: String,
username: Option<String>,
password: Option<String>,
timeout: Duration,
delete_on_upload: bool,
}
impl From<WebdavConfig> for WebdavUploadWorkerConfig {
fn from(value: WebdavConfig) -> Self {
WebdavUploadWorkerConfig {
poll_interval: Duration::from_secs(value.poll_interval_secs),
min_age: TimeDelta::seconds(value.min_age_secs),
url: value.url,
username: value.username,
password: value.password,
timeout: Duration::from_secs(value.upload_timeout_secs),
delete_on_upload: value.delete_on_upload,
}
}
}
#[derive(Debug, Clone)]
struct WebDavClient {
client: Client,
url: String,
username: Option<String>,
password: Option<String>,
}
impl WebDavClient {
fn new(
mut url: String,
username: Option<String>,
password: Option<String>,
timeout: Duration,
) -> Result<Self, reqwest::Error> {
if !url.ends_with('/') {
url.push('/');
}
Ok(Self {
client: reqwest::Client::builder().timeout(timeout).build()?,
url,
username,
password,
})
}
async fn try_upload_file(&self, file: File, name: &str) -> anyhow::Result<Response> {
let file_size = file.metadata().await?.len();
let stream = ReaderStream::new(file);
let body = Body::wrap_stream(stream);
let target = format!("{}{}", self.url, name);
let client = self
.client
.put(&target)
.header(CONTENT_TYPE, "application/octet-stream")
.header(CONTENT_LENGTH, file_size);
let client = match (&self.username, &self.password) {
(Some(username), Some(password)) => client.basic_auth(username, Some(password)),
(Some(username), None) => client.basic_auth(username, None::<&str>),
(None, None) => client,
(None, Some(_)) => {
warn!(
"Got WebDAV auth setting with no username but with a password, skipping authentication"
);
client
}
};
let resp = client.body(body).send().await?.error_for_status();
Ok(resp?)
}
}
async fn try_upload_entry(
client: WebDavClient,
store: Arc<RwLock<RecordingStore>>,
entry_name: String,
file_kind: FileKind,
shutdown_token: CancellationToken,
) -> Option<()> {
let read_lock = store.read().await;
let entry_idx = read_lock.entry_for_name(&entry_name)?.0;
let file = read_lock.open_file(entry_idx, file_kind).await;
drop(read_lock);
let file = match file {
Ok(Some(f)) => f,
Ok(None) => return Some(()), // File doesn't exist (e.g., GPS for old recordings)
Err(err) => {
warn!(
"Unable to open entry: {} {} file: {:?}",
entry_name, file_kind, err
);
return None;
}
};
let file_name = file_kind.get_filename(&entry_name);
let res = select! {
_ = shutdown_token.cancelled() => {
warn!(
"Cancelling upload for entry {} {} file: received shutdown signal",
entry_name, file_kind
);
return None;
},
res = client.try_upload_file(file, &file_name) => res,
};
match res {
Ok(_) => {
info!("Uploaded {} file for entry {}", file_kind, entry_name);
Some(())
}
Err(err) => {
warn!(
"Failed to upload {} file for entry {}: {:?}",
file_kind, entry_name, err
);
None
}
}
}
pub fn run_webdav_upload_worker(
task_tracker: &TaskTracker,
shutdown_token: CancellationToken,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
config: WebdavUploadWorkerConfig,
) {
task_tracker.spawn(async move {
let mut interval = time::interval(config.poll_interval);
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
let webdav_client = match WebDavClient::new(
config.url,
config.username,
config.password,
config.timeout,
) {
Ok(client) => client,
Err(err) => {
warn!("Unable to create WebDAV client: {:?}", err);
return;
}
};
loop {
select! {
_ = shutdown_token.cancelled() => break,
_ = interval.tick() => {
loop {
let Some(unuploaded_entry) = qmdl_store_lock
.read()
.await
.get_next_unuploaded_entry(config.min_age) else {
break;
};
let upload_futures: Vec<_> = FileKind::ALL
.iter()
.map(|&file_kind| {
try_upload_entry(
webdav_client.clone(),
qmdl_store_lock.clone(),
unuploaded_entry.clone(),
file_kind,
shutdown_token.clone(),
)
})
.collect();
let results = join_all(upload_futures).await;
if !results.iter().all(|r| r.is_some()) {
break;
}
if config.delete_on_upload {
match qmdl_store_lock.write().await.delete_entry(&unuploaded_entry).await {
Ok(_) => info!("Successfully deleted entry: {} after upload to WebDAV", unuploaded_entry),
Err(err) => warn!("Unable to delete entry: {} after upload to WebDAV: {}", unuploaded_entry, err),
}
} else {
match qmdl_store_lock.write().await.mark_entry_as_uploaded(&unuploaded_entry, rayhunter::clock::get_adjusted_now()).await {
Ok(_) => info!("Successfully marked entry: {} as uploaded", unuploaded_entry),
Err(err) => warn!("Unable to mark entry: {} as uploaded: {}", unuploaded_entry, err),
}
}
}
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GpsMode;
use axum::{
Router,
body::Bytes,
extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode},
routing::put,
};
use tempfile::Builder;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[derive(Clone, Debug)]
struct RecordedPut {
path: String,
auth: Option<String>,
body: Vec<u8>,
}
async fn capture_put(
State(state): State<Arc<Mutex<Vec<RecordedPut>>>>,
AxumPath(path): AxumPath<String>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(String::from);
state.lock().await.push(RecordedPut {
path,
auth,
body: body.to_vec(),
});
StatusCode::CREATED
}
async fn setup_webdav_server() -> (Arc<Mutex<Vec<RecordedPut>>>, String) {
crate::crypto_provider::install_default();
let state = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/{*path}", put(capture_put))
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}/dav", addr);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
(state, url)
}
async fn cleanup_worker(shutdown: CancellationToken, tracker: TaskTracker) {
shutdown.cancel();
tracker.close();
tracker.wait().await;
}
async fn make_store_with_closed_entry(
dir: &std::path::Path,
) -> (Arc<RwLock<RecordingStore>>, String) {
let mut store = RecordingStore::create(dir).await.unwrap();
let (mut qmdl_file, mut analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap();
qmdl_file.write_all(b"fake qmdl payload").await.unwrap();
qmdl_file.flush().await.unwrap();
analysis_file
.write_all(b"fake ndjson payload")
.await
.unwrap();
analysis_file.flush().await.unwrap();
let entry_index = store.current_entry.unwrap();
let name = store.manifest.entries[entry_index].name.clone();
store.update_entry_qmdl_size(entry_index, 17).await.unwrap();
store.close_current_entry().await.unwrap();
(Arc::new(RwLock::new(store)), name)
}
#[tokio::test]
async fn test_webdav_upload_worker_uploads_entry() {
let (captured, url) = setup_webdav_server().await;
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let config = WebdavUploadWorkerConfig {
poll_interval: Duration::from_millis(50),
min_age: TimeDelta::seconds(-1),
url,
username: Some("user".to_string()),
password: Some("password".to_string()),
timeout: Duration::from_secs(1),
delete_on_upload: false,
};
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(shutdown, tracker).await;
let recorded = captured.lock().await;
assert_eq!(recorded.len(), 3);
let paths: Vec<&str> = recorded.iter().map(|r| r.path.as_str()).collect();
let qmdl_path = format!("dav/{}.qmdl", entry_name);
let ndjson_path = format!("dav/{}.ndjson", entry_name);
let gps_path = format!("dav/{}-gps.ndjson", entry_name);
assert!(paths.contains(&qmdl_path.as_str()));
assert!(paths.contains(&ndjson_path.as_str()));
assert!(paths.contains(&gps_path.as_str()));
for put in recorded.iter() {
assert_eq!(put.auth.as_deref(), Some("Basic dXNlcjpwYXNzd29yZA=="));
}
let qmdl_body = recorded
.iter()
.find(|r| r.path == qmdl_path)
.unwrap()
.body
.clone();
let ndjson_body = recorded
.iter()
.find(|r| r.path == ndjson_path)
.unwrap()
.body
.clone();
drop(recorded);
assert_eq!(qmdl_body, b"fake qmdl payload");
assert_eq!(ndjson_body, b"fake ndjson payload");
let store_read = store.read().await;
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
assert!(entry.upload_time.is_some());
}
#[tokio::test]
async fn test_webdav_upload_worker_deletes_when_configured() {
let (captured, url) = setup_webdav_server().await;
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let config = WebdavUploadWorkerConfig {
poll_interval: Duration::from_millis(50),
min_age: TimeDelta::seconds(-1),
url,
username: None,
password: None,
timeout: Duration::from_secs(1),
delete_on_upload: true,
};
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(shutdown, tracker).await;
assert_eq!(captured.lock().await.len(), 3);
let store_read = store.read().await;
assert!(store_read.entry_for_name(&entry_name).is_none());
}
#[tokio::test]
async fn test_webdav_upload_worker_respects_min_age() {
let (captured, url) = setup_webdav_server().await;
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let config = WebdavUploadWorkerConfig {
poll_interval: Duration::from_millis(50),
min_age: TimeDelta::seconds(3600),
url,
username: None,
password: None,
timeout: Duration::from_secs(1),
delete_on_upload: false,
};
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(shutdown, tracker).await;
assert!(captured.lock().await.is_empty());
let store_read = store.read().await;
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
assert!(entry.upload_time.is_none());
}
}

185
daemon/tests/smoke.rs Normal file
View File

@@ -0,0 +1,185 @@
use std::io::Read;
use std::net::{TcpListener, TcpStream};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tempfile::TempDir;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
struct DaemonGuard {
child: Option<Child>,
stderr: Arc<Mutex<Vec<u8>>>,
}
impl DaemonGuard {
fn stderr_dump(&self) -> String {
let buf = self.stderr.lock().unwrap();
String::from_utf8_lossy(&buf).into_owned()
}
fn shutdown(&mut self, timeout: Duration) -> std::io::Result<ExitStatus> {
let mut child = self
.child
.take()
.expect("daemon already shut down or never started");
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: child.id() returns the OS pid of a process we own.
unsafe { libc::kill(pid, libc::SIGINT) };
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
let start = Instant::now();
loop {
match child.try_wait()? {
Some(status) => return Ok(status),
None => {
if start.elapsed() >= timeout {
let _ = child.kill();
return child.wait();
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
}
}
impl Drop for DaemonGuard {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn pick_free_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
listener.local_addr().expect("local_addr").port()
}
fn wait_for_port(port: u16, timeout: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
#[tokio::test(flavor = "current_thread")]
async fn daemon_serves_index_and_api() {
let port = pick_free_port();
let tmp = TempDir::new().unwrap();
let qmdl_dir = tmp.path().join("qmdl");
std::fs::create_dir(&qmdl_dir).unwrap();
// The daemon refuses to create a store in debug_mode, so seed an empty
// manifest. See init_qmdl_store in daemon/src/main.rs.
std::fs::write(qmdl_dir.join("manifest.toml"), "entries = []\n").unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(
&config_path,
format!(
"qmdl_store_path = \"{}\"\nport = {}\ndebug_mode = true\n",
qmdl_dir.display(),
port,
),
)
.unwrap();
let daemon_bin = env!("CARGO_BIN_EXE_rayhunter-daemon");
let mut child = Command::new(daemon_bin)
.arg(&config_path)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn daemon");
let stderr_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
if let Some(mut pipe) = child.stderr.take() {
let sink = stderr_buf.clone();
std::thread::spawn(move || {
let mut chunk = [0u8; 4096];
loop {
match pipe.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => sink.lock().unwrap().extend_from_slice(&chunk[..n]),
}
}
});
}
let mut guard = DaemonGuard {
child: Some(child),
stderr: stderr_buf,
};
if !wait_for_port(port, STARTUP_TIMEOUT) {
panic!(
"daemon did not start listening on {port} within {STARTUP_TIMEOUT:?}\n--- daemon stderr ---\n{}",
guard.stderr_dump(),
);
}
// reqwest's rustls backend gets pulled in via feature unification with the
// daemon's production deps. The test process needs its own crypto provider.
rayhunter_daemon::crypto_provider::install_default();
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.gzip(true)
.build()
.unwrap();
let base = format!("http://127.0.0.1:{port}");
let resp = client
.get(format!("{base}/index.html"))
.send()
.await
.expect("GET /index.html failed");
assert!(
resp.status().is_success(),
"GET /index.html returned {}",
resp.status(),
);
let body = resp.text().await.expect("could not read index.html body");
assert!(
body.contains("Rayhunter"),
"decompressed index.html body did not contain 'Rayhunter' marker (len={})",
body.len(),
);
let resp = client
.get(format!("{base}/api/qmdl-manifest"))
.send()
.await
.expect("GET /api/qmdl-manifest failed");
assert!(
resp.status().is_success(),
"GET /api/qmdl-manifest returned {}",
resp.status(),
);
let status = guard
.shutdown(SHUTDOWN_TIMEOUT)
.expect("waiting for daemon exit failed");
assert!(
status.success(),
"daemon did not exit cleanly after SIGINT: {status}\n--- daemon stderr ---\n{}",
guard.stderr_dump(),
);
}

View File

@@ -22,7 +22,7 @@ export default ts.config(
},
},
{
files: ['**/*.svelte'],
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
@@ -37,6 +37,22 @@ export default ts.config(
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'function',
format: ['snake_case'],
},
{
selector: 'method',
format: ['snake_case'],
},
],
// these rules should eventually be enabled, just disabled them to
// make dependency upgrades easier.
'svelte/prefer-svelte-reactivity': 'off',
'svelte/require-each-key': 'off',
'svelte/no-navigation-without-resolve': 'off',
},
}
);

File diff suppressed because it is too large Load Diff

View File

@@ -15,25 +15,26 @@
"fix": "eslint --fix ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.13.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@types/eslint": "^9.6.0",
"@types/node": "^24.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^7.1.11",
"vitest": "^3.2.4"
"@types/node": "^25.6.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.5.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.55.7",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.10",
"vitest": "^4.1.5"
}
}

View File

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

View File

@@ -1,3 +1,16 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'tailwindcss';
@theme {
--color-rayhunter-blue: #4e4eb1;
--color-rayhunter-dark-blue: #3f3da0;
--color-rayhunter-green: #94ea18;
}
/* v4 dropped the v3 preflight rule that set `cursor: pointer` on buttons.
* Restore it so enabled buttons get the pointer cursor. */
@layer base {
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}

View File

@@ -20,7 +20,7 @@
{#if action_errors.length > 0}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
class="bg-red-100 border-red-100 drop-shadow-sm 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">

View File

@@ -11,7 +11,7 @@
dateStyle: 'short',
});
const analyzers = report.metadata.analyzers;
const analyzers = $derived(report.metadata.analyzers);
const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map();
@@ -78,7 +78,8 @@
<p class="text-lg underline">Unparsed Messages</p>
<p>
These are due to a limitation or bug in Rayhunter's parser, and aren't usually a
problem.
problem. We'll not accept bug reports about them unless something else is going wrong
(such as false-positives or definite false-negatives)
</p>
<div class="overflow-x-auto">
<table class="table-auto text-left">

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { type ReportMetadata } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
import { gps_mode_label } from '$lib/utils.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import AnalysisTable from './AnalysisTable.svelte';
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
@@ -13,6 +14,11 @@
manager: AnalysisManager;
current: boolean;
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: 'long',
dateStyle: 'short',
});
</script>
<div class="container mt-2">
@@ -22,10 +28,26 @@
<p>Error getting analysis report: {entry.analysis_report}</p>
{:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
{@const numWarnings: number = entry.get_num_warnings() || 0}
<div class="flex flex-col gap-2">
{#if !current}
<div class="flex flex-row justify-end items-center">
<ReAnalyzeButton {entry} {manager} />
{#if !!numWarnings || !current}
<div class="flex flex-row justify-between items-center">
{#if !!numWarnings}
<div
class="text-red-700 border-red-500 border rounded-lg text-blue-600 px-2 py-1 mr-12"
>
Your Rayhunter device raised {`${numWarnings}`} warning{`${
numWarnings > 1 ? 's' : ''
}`}!
<a
href="https://efforg.github.io/rayhunter/faq.html#red"
class="text-blue-600 underline">Read the FAQ</a
> to learn what you can do about it
</div>
{/if}
{#if !current}
<ReAnalyzeButton {entry} {manager} />
{/if}
</div>
{/if}
{#if entry.analysis_report.rows.length > 0}
@@ -33,20 +55,34 @@
{: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>
<div>
<p class="text-lg underline">Metadata</p>
{#if metadata !== undefined && metadata.rayhunter !== undefined}
<p><b>Rayhunter version:</b> {metadata.rayhunter.rayhunter_version}</p>
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
</div>
{:else}
<p>N/A (analysis generated by an older version of rayhunter)</p>
{/if}
{#if entry.upload_time}
<p>
<b>WebDAV uploaded at:</b>
<span class="text-green-700"
>{date_formatter.format(entry.upload_time)}</span
>
</p>
{/if}
<p>
<b>GPS Mode:</b>
{gps_mode_label(entry.gps_mode)}
</p>
</div>
{#if metadata && metadata.analyzers}
<div>
<p class="text-lg underline">Analyzers</p>
<p class="text-lg underline">Enabled 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}

View File

@@ -12,6 +12,7 @@
onclick,
ariaLabel,
errorMessage,
jsonBody,
}: {
url: string;
method?: string;
@@ -23,6 +24,7 @@
onclick?: () => void | Promise<void>;
ariaLabel?: string;
errorMessage?: string;
jsonBody?: unknown;
} = $props();
let is_requesting = $state(false);
@@ -43,7 +45,7 @@
},
};
async function handleClick() {
async function handle_click() {
if (is_disabled) return;
is_requesting = true;
@@ -51,7 +53,8 @@
await user_action_req(
method,
url,
errorMessage ? errorMessage : 'Error performing action'
errorMessage ? errorMessage : 'Error performing action',
jsonBody
);
if (onclick) {
await onclick();
@@ -71,7 +74,7 @@
<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}
onclick={handle_click}
disabled={is_disabled}
aria-label={ariaLabel || label}
>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { get_daemon_time } from '$lib/utils.svelte';
import ApiRequestButton from './ApiRequestButton.svelte';
let show_alert = $state(false);
let device_system_time = $state('');
let device_adjusted_time = $state('');
let browser_time = $state('');
let has_offset = $state(false);
let computed_offset = $state(0);
let dismissed = $state(false);
let check_completed = $state(false);
const DRIFT_THRESHOLD_SECONDS = 30;
function format_time(date: Date): string {
return date.toLocaleString();
}
async function check_clock_drift() {
if (check_completed) return;
try {
const daemon_time_response = await get_daemon_time();
const browser_now = new Date();
const daemon_system_ms = new Date(daemon_time_response.system_time).getTime();
const device_adjusted_ms = new Date(daemon_time_response.adjusted_time).getTime();
const drift_seconds = Math.round((browser_now.getTime() - device_adjusted_ms) / 1000);
if (Math.abs(drift_seconds) > DRIFT_THRESHOLD_SECONDS && !dismissed) {
device_system_time = format_time(new Date(daemon_time_response.system_time));
device_adjusted_time = format_time(new Date(daemon_time_response.adjusted_time));
browser_time = format_time(browser_now);
has_offset = daemon_time_response.offset_seconds !== 0;
// Calculate offset needed: browser_time - daemon_system_time
computed_offset = Math.round((browser_now.getTime() - daemon_system_ms) / 1000);
show_alert = true;
}
} catch (err) {
console.error('Failed to check clock drift:', err);
}
check_completed = true;
}
function dismiss() {
show_alert = false;
dismissed = true;
}
// Check clock drift on component mount
$effect(() => {
check_clock_drift();
});
</script>
{#if show_alert}
<div
class="bg-yellow-100 border-yellow-400 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md"
>
<span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
<svg
class="w-6 h-6 text-yellow-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 0v4a1 1 0 0 0 .293.707l3 3a1 1 0 0 0 1.414-1.414L13 11.586V8Z"
clip-rule="evenodd"
/>
</svg>
Clock Mismatch Detected
</span>
<p>
Rayhunter's clock doesn't match your browser's, and may be incorrect. This can happen if
Rayhunter is unable to get the correct time from the internet. Consider synchronizing
your browser's clock with the button below, or using another SIM card for better
results.
</p>
<table class="w-fit">
<tbody>
<tr>
<td class="pr-2">Rayhunter clock (system):</td>
<td class="font-mono">{device_system_time}</td>
</tr>
{#if has_offset}
<tr>
<td class="pr-2">Rayhunter clock (adjusted):</td>
<td class="font-mono">{device_adjusted_time}</td>
</tr>
{/if}
<tr>
<td class="pr-2">Browser clock:</td>
<td class="font-mono">{browser_time}</td>
</tr>
</tbody>
</table>
<p>Copy browser clock to device?</p>
<div class="flex flex-row gap-2 justify-end">
<button
class="font-medium py-2 px-4 rounded-md border border-gray-400 hover:bg-yellow-200"
onclick={dismiss}
>
Dismiss
</button>
<ApiRequestButton
url="/api/time-offset"
label="Sync Clock"
loadingLabel="Syncing..."
variant="green"
jsonBody={{ offset_seconds: computed_offset }}
onclick={dismiss}
errorMessage="Error syncing clock"
/>
</div>
</div>
{/if}

View File

@@ -1,20 +1,43 @@
<script lang="ts">
import { get_config, set_config, type Config } from '../utils.svelte';
import {
get_config,
set_config,
test_notification,
get_wifi_status,
scan_wifi_networks,
GpsMode,
enabled_notifications,
type Config,
type WifiStatus,
type WifiNetwork,
} from '../utils.svelte';
import Modal from './Modal.svelte';
import ExpandableInput from './ExpandableInput.svelte';
let { shown = $bindable() }: { shown: boolean } = $props();
let config = $state<Config | null>(null);
let loading = $state(false);
let saving = $state(false);
let testingNotification = $state(false);
let message = $state('');
let messageType = $state<'success' | 'error' | null>(null);
let showConfig = $state(false);
let testMessage = $state('');
let testMessageType = $state<'success' | 'error' | null>(null);
let wifiStatus = $state<WifiStatus | null>(null);
let wifiStatusTimer = $state<ReturnType<typeof setInterval> | null>(null);
let scanning = $state(false);
let scanResults = $state<WifiNetwork[]>([]);
let dnsServersInput = $state('');
async function loadConfig() {
async function load_config() {
try {
loading = true;
config = await get_config();
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
message = '';
messageType = null;
poll_wifi_status();
} catch (error) {
message = `Failed to load config: ${error}`;
messageType = 'error';
@@ -23,9 +46,18 @@
}
}
async function saveConfig() {
async function save_config() {
if (!config) return;
const trimmed = dnsServersInput.trim();
config.dns_servers =
trimmed.length > 0
? trimmed
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
: null;
try {
saving = true;
await set_config(config);
@@ -40,32 +72,84 @@
}
}
// Load config when first shown
$effect(() => {
if (showConfig && !config) {
loadConfig();
async function poll_wifi_status() {
if (wifiStatusTimer) clearInterval(wifiStatusTimer);
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
wifiStatusTimer = setInterval(async () => {
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
}, 5000);
}
let scanError = $state('');
async function do_scan() {
scanning = true;
scanError = '';
try {
scanResults = await scan_wifi_networks();
} catch (error) {
scanResults = [];
scanError = `Scan failed: ${error}`;
} finally {
scanning = false;
}
}
function select_network(network: WifiNetwork) {
if (config) {
config.wifi_ssid = network.ssid;
config.wifi_password = '';
config.wifi_security =
network.security === 'WPA3' || network.security === 'WPA3 (transition)'
? 'sae'
: 'wpa_psk';
scanResults = [];
}
}
async function send_test_notification() {
try {
testingNotification = true;
testMessage = '';
testMessageType = null;
await test_notification();
testMessage = 'Test notification sent successfully!';
testMessageType = 'success';
} catch (error) {
testMessage = `${error}`;
testMessageType = 'error';
} finally {
testingNotification = false;
}
}
$effect(() => {
if (shown && !config) {
load_config();
}
if (!shown && wifiStatusTimer) {
clearInterval(wifiStatusTimer);
wifiStatusTimer = null;
}
return () => {
if (wifiStatusTimer) {
clearInterval(wifiStatusTimer);
wifiStatusTimer = null;
}
};
});
</script>
<div class="bg-white rounded-lg shadow-md p-6 m-4">
<button
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)}
>
<span>Configuration</span>
<svg
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>
</button>
{#if showConfig}
<Modal bind:shown title="Configuration">
<div class="p-2">
{#if loading}
<div class="text-center py-4">Loading config...</div>
{:else if config}
@@ -73,7 +157,7 @@
class="space-y-4"
onsubmit={(e) => {
e.preventDefault();
saveConfig();
save_config();
}}
>
<div>
@@ -83,13 +167,18 @@
<select
id="ui_level"
bind:value={config.ui_level}
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-hidden focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Invisible mode</option>
<option value={1}>1 - Subtle mode (colored line)</option>
<option value={2}>2 - Demo mode (orca gif)</option>
<option value={3}>3 - EFF logo</option>
<option value={0}>Invisible mode</option>
<option value={1}>Subtle mode (colored line)</option>
<option value={2}>Demo mode (orca gif)</option>
<option value={3}>EFF logo</option>
<option value={4}>High visibility (full screen color)</option>
</select>
<p class="text-xs text-gray-500 mt-1">
Note: Rayhunter draws over the device's native UI, so some flickering is
expected
</p>
</div>
<div>
@@ -102,12 +191,10 @@
<select
id="key_input_mode"
bind:value={config.key_input_mode}
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-hidden focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Disable button control</option>
<option value={1}
>1 - Double-tap power button to start/stop recording</option
>
<option value={0}>Disable button control</option>
<option value={1}>Double-tap power button to start new recording</option>
</select>
</div>
@@ -117,7 +204,7 @@
id="colorblind_mode"
type="checkbox"
bind:checked={config.colorblind_mode}
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-sm"
/>
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
Colorblind Mode
@@ -125,57 +212,451 @@
</div>
</div>
<div class="border-t pt-4 mt-6 space-y-3">
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
<div class="flex items-center">
<input
id="auto_check_updates"
type="checkbox"
bind:checked={config.auto_check_updates}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="auto_check_updates" class="ml-2 block text-sm text-gray-700">
Automatically check for software updates
</label>
</div>
<p class="text-xs text-gray-500">
When enabled, Rayhunter periodically checks GitHub for new releases and
shows an update notice in the web UI.
</p>
<ExpandableInput
bind:value={config.ntfy_url}
checkboxId="ntfy_enabled"
inputId="ntfy_url"
label="Enable ntfy notifications"
inputLabel="ntfy URL"
inputPlaceholder="https://ntfy.sh/my-rayhunter"
inputHelp="Test button below uses the saved configuration URL, not the input above"
>
<div>
<button
type="button"
onclick={send_test_notification}
disabled={testingNotification}
class="bg-rayhunter-blue hover:bg-rayhunter-dark-blue disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
>
{#if testingNotification}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
Sending...
{:else}
<svg
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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
></path>
</svg>
Send Test Notification
{/if}
</button>
{#if testMessage}
<div
class="mt-2 p-2 rounded-sm text-sm {testMessageType === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}"
>
{testMessage}
</div>
{/if}
</div>
<div class="space-y-2">
<div class="block text-sm font-medium text-gray-700 mb-1">
Enabled Notification Types
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_warning_notifications"
value="Warning"
bind:group={config.enabled_notifications}
/>
<label
for="enable_warning_notifications"
class="ml-2 block text-sm text-gray-700"
>
Warnings
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_lowbattery_notifications"
value="LowBattery"
bind:group={config.enabled_notifications}
/>
<label
for="enable_lowbattery_notifications"
class="ml-2 block text-sm text-gray-700"
>
Low Battery
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_update_notifications"
value={enabled_notifications.Update}
bind:group={config.enabled_notifications}
/>
<label
for="enable_update_notifications"
class="ml-2 block text-sm text-gray-700"
>
Software Updates
</label>
</div>
</div>
</ExpandableInput>
</div>
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
<div>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
ntfy URL for Sending Notifications (if unset you will not receive
notifications)
<label
for="min_space_to_start_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Start Recording (MB)
</label>
<input
id="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"
id="min_space_to_start_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_start_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will not start if less than this amount of disk space is free
</p>
</div>
<div class="space-y-2">
<div class="block text-sm font-medium text-gray-700 mb-1">
Enabled Notification Types
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_warning_notifications"
value="Warning"
bind:group={config.enabled_notifications}
/>
<label
for="enable_warning_notifications"
class="ml-2 block text-sm text-gray-700"
>
Warnings
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_lowbattery_notifications"
value="LowBattery"
bind:group={config.enabled_notifications}
/>
<label
for="enable_lowbattery_notifications"
class="ml-2 block text-sm text-gray-700"
>
Low Battery
</label>
</div>
<div>
<label
for="min_space_to_continue_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Continue Recording (MB)
</label>
<input
id="min_space_to_continue_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_continue_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will stop automatically if disk space drops below this level
</p>
</div>
</div>
<div class="border-t pt-4 mt-6">
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">WebDAV Upload</h3>
<p class="text-xs text-gray-500">
Once a recording has been closed for at least the configured age, both the
.qmdl and .ndjson files are uploaded in the background to the WebDAV server.
</p>
<ExpandableInput
bind:value={config.webdav.url}
checkboxId="webdav_enabled"
inputId="webdav_url"
label="Enable WebDAV upload"
inputLabel="Server URL"
inputPlaceholder="https://dav.example.com/rayhunter/"
inputHelp="Files are uploaded via HTTP PUT under this base URL. No folders are created, and folders in this base URL are assumed to exist already."
>
<div>
<label
for="webdav_username"
class="block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<input
id="webdav_username"
type="text"
bind:value={config.webdav.username}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Optional. Leave blank for unauthenticated uploads.
</p>
</div>
<div>
<label
for="webdav_password"
class="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="webdav_password"
type="password"
bind:value={config.webdav.password}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
A password without a username will be rejected and the request will
be sent unauthenticated.
</p>
</div>
<div>
<label
for="webdav_upload_timeout_secs"
class="block text-sm font-medium text-gray-700 mb-1"
>
Upload Timeout (seconds)
</label>
<input
id="webdav_upload_timeout_secs"
type="number"
min="1"
bind:value={config.webdav.upload_timeout_secs}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
<div>
<label
for="webdav_poll_interval_secs"
class="block text-sm font-medium text-gray-700 mb-1"
>
Poll Interval (seconds)
</label>
<input
id="webdav_poll_interval_secs"
type="number"
min="1"
bind:value={config.webdav.poll_interval_secs}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
How often the worker checks for new entries to upload.
</p>
</div>
<div>
<label
for="webdav_min_age_secs"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Age Before Upload (seconds)
</label>
<input
id="webdav_min_age_secs"
type="number"
min="0"
bind:value={config.webdav.min_age_secs}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
How long a recording must be closed before it becomes eligible for
upload.
</p>
</div>
<div class="flex items-center">
<input
id="webdav_delete_on_upload"
type="checkbox"
bind:checked={config.webdav.delete_on_upload}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label
for="webdav_delete_on_upload"
class="ml-2 block text-sm text-gray-700"
>
Delete on successful upload
</label>
</div>
<p class="text-xs text-gray-500">
When enabled, the local files are removed after a successful upload.
Otherwise the manifest is just marked as uploaded.
</p>
</ExpandableInput>
</div>
{#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
<p class="text-xs text-gray-500">
Connect the device to an existing WiFi network for internet access (e.g.
notifications, remote access). The hotspot AP stays running alongside
WiFi client mode.
</p>
<div class="flex items-center">
<input
id="wifi_enabled"
type="checkbox"
bind:checked={config.wifi_enabled}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
Enable WiFi
</label>
</div>
<p class="text-xs text-gray-500">
Unchecking stops WiFi without clearing saved credentials.
</p>
{#if wifiStatus && config.wifi_enabled}
{#if wifiStatus.state === 'connected'}
<p class="text-xs text-green-600">
Connected to "{wifiStatus.ssid}" ({wifiStatus.ip})
</p>
{:else if wifiStatus.state === 'connecting'}
<p class="text-xs text-amber-600">Connecting...</p>
{:else if wifiStatus.state === 'recovering'}
<p class="text-xs text-amber-600">Recovering connection...</p>
{:else if wifiStatus.state === 'dataPathDead'}
<p class="text-xs text-amber-600">
Data path stalled, attempting recovery...
</p>
{:else if wifiStatus.state === 'failed'}
<p class="text-xs text-red-600">
Failed: {wifiStatus.error}
</p>
{/if}
{/if}
<div>
<label
for="wifi_ssid"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Network Name (SSID)
</label>
<div class="flex gap-2">
<input
id="wifi_ssid"
type="text"
bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<button
type="button"
onclick={do_scan}
disabled={scanning}
class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border border-gray-300 rounded-md"
>
{scanning ? 'Scanning...' : 'Scan'}
</button>
</div>
</div>
{#if scanError}
<p class="text-xs text-red-600">{scanError}</p>
{/if}
{#if scanResults.length > 0}
<div
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y divide-gray-200"
>
{#each scanResults as network}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex justify-between"
onclick={() => select_network(network)}
>
<span>{network.ssid}</span>
<span class="text-gray-400"
>{network.signal_dbm} dBm &middot; {network.security}</span
>
</button>
{/each}
</div>
{/if}
{#if config.wifi_ssid}
<div>
<label
for="wifi_security"
class="block text-sm font-medium text-gray-700 mb-1"
>
Security Type
</label>
<select
id="wifi_security"
bind:value={config.wifi_security}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
>
<option value="wpa_psk">WPA2 (WPA-PSK)</option>
<option value="sae">WPA3 (SAE)</option>
</select>
</div>
{/if}
<div>
<label
for="wifi_password"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Password
</label>
<input
id="wifi_password"
type="password"
bind:value={config.wifi_password}
placeholder="Enter password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Changing the network requires re-entering the password.
</p>
</div>
{#if config.wifi_ssid}
<div>
<label
for="dns_servers"
class="block text-sm font-medium text-gray-700 mb-1"
>
DNS Servers
</label>
<input
id="dns_servers"
type="text"
bind:value={dnsServersInput}
placeholder="9.9.9.9, 149.112.112.112"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9,
149.112.112.112 (Quad9).
</p>
</div>
{/if}
</div>
{/if}
<div class="border-t border-gray-200 pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings
</h3>
@@ -185,7 +666,7 @@
id="imsi_requested"
type="checkbox"
bind:checked={config.analyzers.imsi_requested}
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-sm"
/>
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
IMSI Requested Heuristic
@@ -197,7 +678,7 @@
id="connection_redirect_2g_downgrade"
type="checkbox"
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-sm"
/>
<label
for="connection_redirect_2g_downgrade"
@@ -212,7 +693,7 @@
id="lte_sib6_and_7_downgrade"
type="checkbox"
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-sm"
/>
<label
for="lte_sib6_and_7_downgrade"
@@ -227,7 +708,7 @@
id="null_cipher"
type="checkbox"
bind:checked={config.analyzers.null_cipher}
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-sm"
/>
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
Null Cipher Heuristic
@@ -239,7 +720,7 @@
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"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
NAS Null Cipher Heuristic
@@ -251,7 +732,7 @@
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"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic
@@ -263,15 +744,97 @@
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"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisy!)
</label>
</div>
<div class="flex items-center">
<input
id="diagnostic_analyzer"
type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label
for="diagnostic_analyzer"
class="ml-2 block text-sm text-gray-700"
>
Diagnostic Analyzer
</label>
</div>
</div>
</div>
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">GPS Settings</h3>
<div>
<label for="gps_mode" class="block text-sm font-medium text-gray-700 mb-1"
>GPS Mode</label
>
<select
id="gps_mode"
bind:value={config.gps_mode}
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={GpsMode.Disabled}>Disabled</option>
<option value={GpsMode.Fixed}>Fixed coordinates</option>
<option value={GpsMode.Api}>API endpoint</option>
</select>
<p class="text-xs text-gray-500 mt-1">
{#if config.gps_mode === GpsMode.Api}
POST latitude and longitude to <code>/api/gps</code> from any device on
the network. Timestamp is derived from packet capture timing.
{:else if config.gps_mode === GpsMode.Fixed}
GPS coordinates are fixed to the values below.
{:else}
GPS is disabled; no coordinates will be tracked.
{/if}
</p>
</div>
{#if config.gps_mode === GpsMode.Fixed}
<div>
<label
for="gps_fixed_latitude"
class="block text-sm font-medium text-gray-700 mb-1"
>Fixed Latitude</label
>
<input
id="gps_fixed_latitude"
type="number"
min="-90"
max="90"
step="any"
required
bind:value={config.gps_fixed_latitude}
placeholder="e.g. 37.7749"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -90 to 90</p>
</div>
<div>
<label
for="gps_fixed_longitude"
class="block text-sm font-medium text-gray-700 mb-1"
>Fixed Longitude</label
>
<input
id="gps_fixed_longitude"
type="number"
min="-180"
max="180"
step="any"
required
bind:value={config.gps_fixed_longitude}
placeholder="e.g. -122.4194"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -180 to 180</p>
</div>
{/if}
</div>
<div class="flex gap-2 pt-4">
<button
type="submit"
@@ -304,7 +867,7 @@
</form>
{#if message}
<div
class="mt-4 p-3 rounded {messageType === 'error'
class="mt-4 p-3 rounded-sm {messageType === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}"
>
@@ -316,5 +879,5 @@
Failed to load configuration. Please try reloading the page.
</div>
{/if}
{/if}
</div>
</div>
</Modal>

View File

@@ -5,8 +5,8 @@
<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`}
prompt="Are you sure you want to delete ALL recordings?"
url="/api/delete-all-recordings"
name="all recodings"
/>
</div>

View File

@@ -12,7 +12,7 @@
name: string;
} = $props();
function confirmDelete() {
function confirm_delete() {
if (window.confirm(prompt)) {
user_action_req('POST', url, 'Unable to delete recording ' + name);
}
@@ -21,7 +21,7 @@
<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}
onclick={confirm_delete}
aria-label="delete"
>
<p>{text}</p>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
value = $bindable(''),
checkboxId,
inputId,
label,
inputLabel,
inputPlaceholder = '',
inputHelp = '',
children,
}: {
value: string | null;
checkboxId: string;
inputId: string;
label: string;
inputLabel: string;
inputPlaceholder?: string;
inputHelp?: string;
children?: Snippet;
} = $props();
function has_value(text: string | null) {
return text !== null && text.trim() !== '';
}
let expanded = $state(has_value(value));
let inputElement = $state<HTMLInputElement | null>(null);
function handle_checkbox_change(e: Event) {
expanded = (e.currentTarget as HTMLInputElement).checked;
if (expanded) {
setTimeout(() => inputElement?.focus(), 0);
} else {
value = '';
}
}
function handle_input_blur() {
if (!has_value(value)) {
expanded = false;
}
}
</script>
<div class="flex items-center">
<input
id={checkboxId}
type="checkbox"
checked={expanded}
onchange={handle_checkbox_change}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for={checkboxId} class="ml-2 block text-sm text-gray-700">
{label}
</label>
</div>
{#if expanded}
<div>
<label for={inputId} class="block text-sm font-medium text-gray-700 mb-1">
{inputLabel}
</label>
<input
id={inputId}
type="text"
bind:this={inputElement}
bind:value
onblur={handle_input_blur}
placeholder={inputPlaceholder}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
{#if inputHelp}
<p class="text-xs text-gray-500 mt-1">
{inputHelp}
</p>
{/if}
</div>
{@render children?.()}
{/if}

View File

@@ -1,34 +1,13 @@
<script lang="ts">
import { get_logs } from '$lib/utils.svelte';
import { onMount } from 'svelte';
import Modal from './Modal.svelte';
let { shown = $bindable() }: { shown: boolean } = $props();
let content: string | undefined = $state(undefined);
onMount(() => {
// Used by LogView modal
window.addEventListener('scroll', () => {
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
});
});
$effect(() => {
if (shown) {
const scrollY = document.documentElement.style.getPropertyValue('--scroll-y');
const body = document.body;
body.style.position = 'fixed';
body.style.top = `-${scrollY}`;
} else {
const body = document.body;
const scrollY = body.style.top;
body.style.position = '';
body.style.top = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
}
const interval = setInterval(async () => {
try {
// Don't update UI if browser tab isn't visible
if (content !== undefined && (document.hidden || !shown)) {
return;
}
@@ -42,33 +21,8 @@
});
</script>
{#if shown}
<div
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
flex flex-col p-2 drop-shadow"
>
<div class="flex h-20 justify-between items-center p-1">
<span class="text-2xl mb-2">Log</span>
<button onclick={() => (shown = false)} aria-label="close">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
fill="#0F1729"
/>
</svg>
</button>
</div>
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
<pre class="m-2">{content}</pre>
</div>
<Modal bind:shown title="Logs">
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
<pre class="m-2">{content}</pre>
</div>
{/if}
</Modal>

View File

@@ -44,7 +44,7 @@
</script>
<div
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
class="{status_row_color} {status_border_color} drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
>
{#if current}
<div class="flex flex-row justify-between gap-2">
@@ -81,6 +81,11 @@
'N/A'}</span
>
</div>
{#if entry.stop_reason}
<div class="bg-yellow-50 border border-yellow-300 rounded-sm p-2 text-yellow-800 text-sm">
{entry.stop_reason}
</div>
{/if}
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
@@ -95,7 +100,7 @@
/>
{/if}
</div>
<div class="border-b {analysis_visible ? '' : 'hidden'}">
<div class="border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} {manager} {current} />
</div>
</div>

View File

@@ -16,7 +16,7 @@
{#if $screenIsLgUp}
<table class="table-auto text-left table">
<thead>
<tr class="bg-gray-100 drop-shadow">
<tr class="bg-gray-100 drop-shadow-sm">
<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>

View File

@@ -36,7 +36,7 @@
}
</script>
<tr class="{status_row_color} drop-shadow">
<tr class="{status_row_color} drop-shadow-sm">
<td class="p-2">{entry.name}</td>
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
<td class="p-2"
@@ -65,8 +65,8 @@
</td>
{/if}
</tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="9">
<tr class="{alternating_row_color} border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-gray-200 border-dashed p-2" colspan="9">
<AnalysisView {entry} {manager} {current} />
</td>
</tr>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
let {
shown = $bindable(),
title,
children,
}: { shown: boolean; title: string; children: Snippet } = $props();
onMount(() => {
const handler = () => {
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
};
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
});
$effect(() => {
if (shown) {
const scrollY = document.documentElement.style.getPropertyValue('--scroll-y');
const body = document.body;
body.style.position = 'fixed';
body.style.top = `-${scrollY}`;
} else {
const body = document.body;
const scrollY = body.style.top;
body.style.position = '';
body.style.top = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
}
});
</script>
{#if shown}
<div
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
flex flex-col p-2 drop-shadow-sm"
>
<div class="flex justify-between items-center p-1">
<span class="text-2xl">{title}</span>
<button onclick={() => (shown = false)} aria-label="close">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
fill="#0F1729"
/>
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1">
{@render children()}
</div>
</div>
{/if}

View File

@@ -19,7 +19,7 @@
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
);
async function handleReAnalyze() {
async function handle_re_analyze() {
// Update the entry directly for immediate UI feedback
entry.analysis_status = AnalysisStatus.Queued;
entry.analysis_report = undefined;
@@ -33,7 +33,7 @@
loadingLabel="Analyzing..."
disabled={is_processing}
variant="blue"
onclick={handleReAnalyze}
onclick={handle_re_analyze}
ariaLabel="re-analyze"
errorMessage="Error re-analyzing recoding"
>

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import { type SystemStats } from '$lib/systemStats';
import { gps_mode_label, GpsMode, type GpsData } from '$lib/utils.svelte';
let {
stats,
gps_data = null,
gps_mode = GpsMode.Disabled,
}: {
stats: SystemStats;
gps_data?: GpsData | null;
gps_mode?: GpsMode;
} = $props();
const table_cell_classes = 'border p-1 lg:p-2';
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
let bar_color = $derived.by(() => {
if (stats.battery_status === undefined) {
@@ -36,31 +39,31 @@
</script>
<div
class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
class="flex-1 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
>
<p class="text-xl mb-2">System Information</p>
<table class="table-auto border">
<table class="text-sm w-full">
<tbody>
<tr class="border">
<th class={table_cell_classes}> Rayhunter Version </th>
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
<tr class="border-b border-gray-200">
<td class="py-1 pr-4 text-gray-500 font-medium">Rayhunter Version</td>
<td class="py-1">{stats.runtime_metadata.rayhunter_version}</td>
</tr>
<tr class="border">
<th class={table_cell_classes}> Storage </th>
<td class={table_cell_classes}>
<tr class="border-b border-gray-200">
<td class="py-1 pr-4 text-gray-500 font-medium">Storage</td>
<td class="py-1">
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
.disk_stats.available_size} available)
</td>
</tr>
<tr class="border-b">
<th class={table_cell_classes}> Memory (RAM) </th>
<td class={table_cell_classes}>
<tr class="border-b border-gray-200">
<td class="py-1 pr-4 text-gray-500 font-medium">Memory (RAM)</td>
<td class="py-1">
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td>
</tr>
<tr class="border-b">
<th class={table_cell_classes}> Battery </th>
<td class={table_cell_classes}>
<tr class={gps_mode !== GpsMode.Disabled ? 'border-b border-gray-200' : ''}>
<td class="py-1 pr-4 text-gray-500 font-medium">Battery</td>
<td class="py-1">
<svg
width="80"
height="30"
@@ -70,7 +73,6 @@
class="battery-icon"
>
<title>{title_text}</title>
<!-- Battery body -->
<rect
class="fill-none stroke-neutral-800 stroke-2"
width="70"
@@ -78,7 +80,6 @@
rx="3"
ry="3"
/>
<!-- Battery terminal -->
<rect
class="fill-neutral-800"
x="70"
@@ -88,7 +89,6 @@
rx="2"
ry="2"
/>
<!-- Battery charge bar -->
<rect
class={bar_color}
x="2"
@@ -99,14 +99,12 @@
style="width: {battery_level * 0.66}px;"
/>
{#if stats.battery_status && stats.battery_status.is_plugged_in}
<!-- Lightning bolt icon -->
<path
class="fill-yellow-300 stroke-neutral-800 stroke-1"
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
/>
{/if}
{#if !stats.battery_status}
<!-- Question mark icon -->
<text
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
x="35"
@@ -116,6 +114,27 @@
</svg>
</td>
</tr>
{#if gps_mode !== GpsMode.Disabled}
<tr class="border-b border-gray-200">
<td class="py-1 pr-4 text-gray-500 font-medium">GPS Mode</td>
<td class="py-1">{gps_mode_label(gps_mode)}</td>
</tr>
{#if gps_data}
<tr class="border-b border-gray-200">
<td class="py-1 pr-4 text-gray-500 font-medium">Latitude</td>
<td class="py-1 font-mono">{gps_data.latitude.toFixed(6)}</td>
</tr>
<tr>
<td class="py-1 pr-4 text-gray-500 font-medium">Longitude</td>
<td class="py-1 font-mono">{gps_data.longitude.toFixed(6)}</td>
</tr>
{:else}
<tr>
<td class="py-1 pr-4 text-gray-500 font-medium">GPS Data</td>
<td class="py-1 text-gray-400">Awaiting GPS data...</td>
</tr>
{/if}
{/if}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import type { UpdateStatus } from '$lib/utils.svelte';
let { status = null }: { status: UpdateStatus | null } = $props();
let is_visible = $derived(
Boolean(status?.update_available && status.latest_version && status.latest_release_url)
);
</script>
{#if is_visible && status}
<div class="bg-sky-100 border-sky-300 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md">
<span class="text-xl font-bold flex flex-row items-center gap-2 text-sky-800">
<svg
class="w-6 h-6 text-sky-700"
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>
Software Update Available
</span>
<p>
A new version of Rayhunter is available! You are currently running version {status.current_version},
and the latest release is version {status.latest_version}.
</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span class="text-sm text-sky-900/80">
View the latest release on GitHub to see what's new and download the update.
</span>
<a
class="inline-flex items-center justify-center rounded-md bg-sky-700 px-4 py-2 text-white font-semibold hover:bg-sky-800"
href={status.latest_release_url}
target="_blank"
rel="noreferrer noopener"
aria-label="View latest release on GitHub"
>
View Release
</a>
</div>
</div>
{/if}

View File

@@ -1,5 +1,6 @@
import { get_report, type AnalysisReport } from './analysis.svelte';
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
import { GpsMode } from './utils.svelte';
interface JsonManifest {
entries: JsonManifestEntry[];
@@ -11,6 +12,9 @@ interface JsonManifestEntry {
start_time: string;
last_message_time: string;
qmdl_size_bytes: number;
stop_reason: string | null;
upload_time: string | null;
gps_mode: GpsMode | null;
}
export class Manifest {
@@ -57,6 +61,9 @@ export class ManifestEntry {
public analysis_size_bytes = $state(0);
public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
public stop_reason: string | undefined = $state(undefined);
public upload_time: Date | undefined = $state(undefined);
public gps_mode: GpsMode | undefined = $state(undefined);
constructor(json: JsonManifestEntry) {
this.name = json.name;
@@ -65,6 +72,15 @@ export class ManifestEntry {
if (json.last_message_time) {
this.last_message_time = new Date(json.last_message_time);
}
if (json.stop_reason) {
this.stop_reason = json.stop_reason;
}
if (json.upload_time) {
this.upload_time = new Date(json.upload_time);
}
if (json.gps_mode !== null) {
this.gps_mode = json.gps_mode;
}
}
get_readable_qmdl_size(): string {

View File

@@ -19,7 +19,9 @@ export function parse_ndjson(input: string): NewlineDeliminatedJson {
// however, if we've reached the end of the input, that means we
// were given invalid nd-json
if (lines.length === 0) {
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`);
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`, {
cause: e,
});
}
}
}

View File

@@ -5,7 +5,7 @@ import { breakpoints } from '../../theme';
type Breakpoint = keyof typeof breakpoints;
// Store that tracks if a specific breakpoint matches
export function createBreakpointStore(breakpoint: Breakpoint): Readable<boolean> {
export function create_breakpoint_store(breakpoint: Breakpoint): Readable<boolean> {
return readable<boolean>(false, (set) => {
const width = breakpoints[breakpoint];
const mediaQuery = window.matchMedia(`(min-width: ${width})`);
@@ -23,7 +23,7 @@ export function createBreakpointStore(breakpoint: Breakpoint): Readable<boolean>
}
// Create stores for each breakpoint
export const screenIsSmUp: Readable<boolean> = createBreakpointStore('sm');
export const screenIsMdUp: Readable<boolean> = createBreakpointStore('md');
export const screenIsLgUp: Readable<boolean> = createBreakpointStore('lg');
export const screenIsXlUp: Readable<boolean> = createBreakpointStore('xl');
export const screenIsSmUp: Readable<boolean> = create_breakpoint_store('sm');
export const screenIsMdUp: Readable<boolean> = create_breakpoint_store('md');
export const screenIsLgUp: Readable<boolean> = create_breakpoint_store('lg');
export const screenIsXlUp: Readable<boolean> = create_breakpoint_store('xl');

View File

@@ -18,6 +18,7 @@ export interface DiskStats {
available_size: string;
used_percent: string;
mounted_on: string;
available_bytes?: number;
}
export interface MemoryStats {

View File

@@ -10,31 +10,99 @@ export interface AnalyzerConfig {
nas_null_cipher: boolean;
incomplete_sib: boolean;
test_analyzer: boolean;
diagnostic_analyzer: boolean;
}
export enum enabled_notifications {
Warning = 'Warning',
LowBattery = 'LowBattery',
Update = 'Update',
}
export interface WebdavConfig {
url: string;
username: string | null;
password: string | null;
upload_timeout_secs: number;
poll_interval_secs: number;
min_age_secs: number;
delete_on_upload: boolean;
}
export enum GpsMode {
Disabled = 0,
Fixed = 1,
Api = 2,
}
export function gps_mode_label(mode: GpsMode | undefined | null): string {
switch (mode) {
case GpsMode.Fixed:
return 'Fixed coordinates';
case GpsMode.Api:
return 'API endpoint';
default:
return 'Disabled';
}
}
export interface Config {
device: string;
ui_level: number;
colorblind_mode: boolean;
key_input_mode: number;
ntfy_url: string;
ntfy_url: string | null;
enabled_notifications: enabled_notifications[];
auto_check_updates: boolean;
analyzers: AnalyzerConfig;
min_space_to_start_recording_mb: number;
min_space_to_continue_recording_mb: number;
wifi_ssid: string | null;
wifi_password: string | null;
wifi_security: 'wpa_psk' | 'sae' | null;
wifi_enabled: boolean;
dns_servers: string[] | null;
firewall_restrict_outbound: boolean;
firewall_allowed_ports: number[] | null;
webdav: WebdavConfig;
gps_mode: GpsMode;
gps_fixed_latitude: number | null;
gps_fixed_longitude: number | null;
}
export async function req(method: string, url: string): Promise<string> {
const response = await fetch(url, {
method: method,
});
const body = await response.text();
export interface WifiStatus {
state: string;
ssid?: string;
ip?: string;
error?: string;
}
export interface WifiNetwork {
ssid: string;
signal_dbm: number;
security: string;
}
export async function get_wifi_status(): Promise<WifiStatus> {
return JSON.parse(await req('GET', '/api/wifi-status'));
}
export async function scan_wifi_networks(): Promise<WifiNetwork[]> {
return JSON.parse(await req('POST', '/api/wifi-scan'));
}
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
const options: RequestInit = { method };
if (json_body !== undefined) {
options.body = JSON.stringify(json_body);
options.headers = { 'Content-Type': 'application/json' };
}
const response = await fetch(url, options);
const responseBody = await response.text();
if (response.status >= 200 && response.status < 300) {
return body;
return responseBody;
} else {
throw new Error(body);
throw new Error(responseBody);
}
}
@@ -42,13 +110,13 @@ export async function req(method: string, url: string): Promise<string> {
export async function user_action_req(
method: string,
url: string,
error_msg: string
error_msg: string,
json_body?: unknown
): Promise<string | undefined> {
try {
return await req(method, url);
return await req(method, url, json_body);
} catch (error) {
if (error instanceof Error) {
console.log('beeeo');
add_error(error, error_msg);
}
return undefined;
@@ -86,3 +154,55 @@ export async function set_config(config: Config): Promise<void> {
throw new Error(error);
}
}
export async function test_notification(): Promise<void> {
const response = await fetch('/api/test-notification', {
method: 'POST',
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
}
export interface TimeResponse {
system_time: string;
adjusted_time: string;
offset_seconds: number;
}
export interface UpdateStatus {
current_version: string;
latest_version?: string | null;
latest_release_url?: string | null;
update_available: boolean;
last_checked?: string | null;
last_error?: string | null;
}
export async function get_daemon_time(): Promise<TimeResponse> {
return JSON.parse(await req('GET', '/api/time'));
}
export async function get_update_status(): Promise<UpdateStatus> {
return JSON.parse(await req('GET', '/api/update-status'));
}
export interface GpsData {
latitude: number;
longitude: number;
/** Unix timestamp in seconds (0 = fixed/no real time). */
timestamp: number;
}
export async function get_gps(): Promise<GpsData | null> {
const response = await fetch('/api/gps', { cache: 'no-store' });
if (response.status === 404) {
return null;
}
if (response.status >= 200 && response.status < 300) {
return response.json();
}
throw new Error(await response.text());
}

View File

@@ -1,6 +1,15 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { get_manifest, get_system_stats } from '$lib/utils.svelte';
import {
get_manifest,
get_system_stats,
get_update_status,
get_gps,
get_config,
GpsMode,
type UpdateStatus,
type GpsData,
} from '$lib/utils.svelte';
import ManifestTable from '$lib/components/ManifestTable.svelte';
import Card from '$lib/components/ManifestCard.svelte';
import type { SystemStats } from '$lib/systemStats';
@@ -10,7 +19,9 @@
import RecordingControls from '$lib/components/RecordingControls.svelte';
import ConfigForm from '$lib/components/ConfigForm.svelte';
import ActionErrors from '$lib/components/ActionErrors.svelte';
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
import LogView from '$lib/components/LogView.svelte';
import UpdateNotice from '$lib/components/UpdateNotice.svelte';
let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false);
@@ -20,6 +31,10 @@
let system_stats: SystemStats | undefined = $state(undefined);
let update_error: string | undefined = $state(undefined);
let logview_shown: boolean = $state(false);
let config_shown: boolean = $state(false);
let gps_data: GpsData | null = $state(null);
let gps_mode: GpsMode = $state(GpsMode.Disabled);
let update_status: UpdateStatus | null = $state(null);
$effect(() => {
const interval = setInterval(async () => {
try {
@@ -38,6 +53,16 @@
current_entry = new_manifest.current_entry;
system_stats = await get_system_stats();
// Allow update status to fail
try {
update_status = await get_update_status();
} catch (error) {
console.error('Error fetching update status:', error);
update_status = null;
}
const config = await get_config();
gps_mode = config.gps_mode;
gps_data = await get_gps();
update_error = undefined;
loaded = true;
} catch (error) {
@@ -54,7 +79,10 @@
</script>
<LogView bind:shown={logview_shown} />
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
<ConfigForm bind:shown={config_shown} />
<div
class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow-sm flex flex-row justify-between items-center"
>
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
<div class="flex flex-row gap-4">
@@ -102,6 +130,34 @@
/>
</svg>
</button>
<button onclick={() => (config_shown = true)} class="flex flex-row gap-1 group">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Config</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="M21 13v-2a1 1 0 0 0-1-1h-.757l-.707-1.707.535-.536a1 1 0 0 0 0-1.414l-1.414-1.414a1 1 0 0 0-1.414 0l-.536.535L14 5.757V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v.757L8.293 6.464l-.536-.535a1 1 0 0 0-1.414 0L4.929 7.343a1 1 0 0 0 0 1.414l.535.536L4.757 11H4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h.757l.707 1.707-.535.536a1 1 0 0 0 0 1.414l1.414 1.414a1 1 0 0 0 1.414 0l.536-.535L10 18.243V19a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-.757l1.707-.707.536.535a1 1 0 0 0 1.414 0l1.414-1.414a1 1 0 0 0 0-1.414l-.535-.536.707-1.707H20a1 1 0 0 0 1-1Z"
/>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
/>
</svg>
</button>
<div class="w-px bg-white/30 self-stretch"></div>
<a
class="flex flex-row gap-1 group"
href="https://github.com/EFForg/rayhunter/issues"
@@ -148,12 +204,32 @@
/>
</svg>
</a>
<a
class="flex flex-row gap-1 group"
href="https://supporters.eff.org/donate"
target="_blank"
>
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Donate</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
d="m12.75 20.66 6.184-7.098c2.677-2.884 2.559-6.506.754-8.705-.898-1.095-2.206-1.816-3.72-1.855-1.293-.034-2.652.43-3.963 1.537-1.31-1.108-2.67-1.571-3.962-1.537-1.515.04-2.823.76-3.72 1.855-1.806 2.2-1.924 5.821.753 8.705l6.184 7.098.245.281a.75.75 0 0 0 1.09 0l.246-.281Z"
/>
</svg>
</a>
</div>
</div>
<div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if update_error !== undefined}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
class="bg-red-100 border-red-100 drop-shadow-sm 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
@@ -175,7 +251,7 @@
</span>
<span
>This webpage is not currently receiving updates from your Rayhunter device. This
could be do loss of connection or some issue with your device.</span
could be due to loss of connection or some issue with your device.</span
>
{#if update_error}
<details>
@@ -186,6 +262,8 @@
</div>
{/if}
<ActionErrors />
<ClockDriftAlert />
<UpdateNotice status={update_status} />
{#if loaded}
<div class="flex flex-col lg:flex-row gap-4">
{#if current_entry}
@@ -197,7 +275,7 @@
/>
{: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"
class="bg-red-100 border-red-100 drop-shadow-sm 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"
@@ -227,7 +305,7 @@
</div>
</div>
{/if}
<SystemStatsTable stats={system_stats!} />
<SystemStatsTable stats={system_stats!} {gps_data} {gps_mode} />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
@@ -243,14 +321,13 @@
type="checkbox"
id="filter_threshold"
bind:checked={filter_threshold}
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
</div>
<ManifestTable {entries} server_is_recording={!!current_entry} {manager} />
</div>
<DeleteAllButton />
<ConfigForm />
{:else}
<div class="flex flex-col justify-center items-center">
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->

View File

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

View File

@@ -1,11 +1,12 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
target: process.env.API_TARGET || 'http://localhost:8080',
changeOrigin: true,
secure: false,
configure: (proxy, _options) => {
@@ -26,7 +27,7 @@ export default defineConfig({
},
},
},
plugins: [sveltekit()],
plugins: [tailwindcss(), sveltekit()],
build: {
// Force everything into one HTML file. SvelteKit will still generate
// a lot of JS files but they are deadweight and will not be included

File diff suppressed because it is too large Load Diff

49
dist/config.toml.in vendored
View File

@@ -12,6 +12,7 @@ colorblind_mode = false
# 1 = Subtle mode, display a colored line at the top of the screen when rayhunter is running (green=running, white=paused, red=warnings)
# 2 = Demo Mode, display a fun orca gif
# 3 = display the EFF logo
# 4 = High Visibility mode, fill the entire screen with the status color (green=running, white=paused, red=warnings)
#
# TP-Link with one-bit display:
# 0 = invisible mode
@@ -19,14 +20,57 @@ colorblind_mode = false
ui_level = 1
# 0 = rayhunter does not read button presses
# 1 = double-tapping the power button starts/stops recordings
# 1 = double-tapping the power button starts new recording
key_input_mode = 0
# If set, attempts to send a notification to the url when a new warning is triggered
ntfy_url = ""
# ntfy_url = "https://ntfy.sh/your-topic"
# What notification types to enable. Does nothing if the above ntfy_url is not set.
enabled_notifications = ["Warning", "LowBattery"]
# If true, Rayhunter will periodically check GitHub for new releases and show
# an update notice in the web UI.
auto_check_updates = false
# Disk Space Management
# Minimum free space (MB) required to start recording
min_space_to_start_recording_mb = 1
# Minimum free space (MB) to continue recording (stops if below this)
min_space_to_continue_recording_mb = 1
# WiFi Client Mode
# Toggle wifi_enabled to connect the device to an existing WiFi network.
# Credentials are stored separately in wpa_sta.conf and managed via the web UI.
wifi_enabled = false
# DNS servers to use when WiFi client mode is active.
# Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified.
# dns_servers = ["9.9.9.9", "149.112.112.112"]
# WebDAV Upload
# If a [webdav] section is present, finished recordings (both the raw .qmdl file
# and its .ndjson analysis output) are uploaded in the background to a WebDAV
# server once they've been closed for at least min_age_secs. After a successful
# upload the entry is either marked as uploaded in the manifest, or deleted
# locally if delete_on_upload = true. With no [webdav] section, no upload
# worker runs.
#
# [webdav]
# url = "https://dav.example.com/rayhunter"
# # HTTP Basic auth. Both fields are optional; a password without a username is
# # rejected and the request is sent unauthenticated.
# username = "user"
# password = "pass"
# # Timeout in seconds for each upload request (default 300).
# upload_timeout_secs = 300
# # How often the worker scans for eligible entries (default 3600).
# poll_interval_secs = 3600
# # Minimum age in seconds before an entry becomes eligible for upload
# # (default 86400 = 1 day).
# min_age_secs = 86400
# # Delete the entry locally after a successful upload (default false).
# delete_on_upload = false
# Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
@@ -38,3 +82,4 @@ null_cipher = true
nas_null_cipher = true
incomplete_sib = true
test_analyzer = false
diagnostic_analyzer = true

View File

@@ -1,9 +1,10 @@
# Summary
[Introduction](./introduction.md)
- [Introduction](./introduction.md)
- [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md)
- [Installation](./installation.md)
- [Installing from the latest release](./installing-from-release.md)
- [Installing from the latest release (Windows)](./installing-from-release-windows.md)
- [Installing from source](./installing-from-source.md)
- [Updating Rayhunter](./updating-rayhunter.md)
- [Configuration](./configuration.md)
@@ -13,6 +14,7 @@
- [Re-analyzing recordings](./reanalyzing.md)
- [How we analyze a capture](./analyzing-a-capture.md)
- [Supported devices](./supported-devices.md)
- [Porting to new devices](./porting.md)
- [Orbic/Kajeet RC400L](./orbic.md)
- [TP-Link M7350](./tplink-m7350.md)
- [TP-Link M7310](./tplink-m7310.md)
@@ -21,5 +23,4 @@
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
- [PinePhone and PinePhone Pro](./pinephone.md)
- [Moxee Hotspot](./moxee.md)
- [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md)
- [REST API Documentation](./api-docs.md)

5
doc/api-docs.md Normal file
View File

@@ -0,0 +1,5 @@
# REST API Documentation
The rayhunter daemon has [REST API documentation](./api-docs/) available in the interactive swagger-ui.
>**Note:** API endpoints are subject to change as needs arise, though we will try to keep them as stable as possible and notify about breaking changes in the changelogs for new versions.

View File

@@ -9,15 +9,97 @@ Through web UI you can set:
- *Invisible mode*: Rayhunter does not show anything on the built-in screen
- *Subtle mode (colored line)*: Rayhunter shows green line if there are no warnings, red line if there are warnings (warnings could be checked through web UI) and white line if Rayhunter is not recording.
- *Demo mode (orca gif)*, which shows image of orcas *and* colored line.
- *EFF logo*, which shows EFF logo and *and* colored line.
- *EFF logo*, which shows EFF logo *and* colored line.
- *High visibility (full screen color)*: fills the entire screen with the status color (green for recording, red for warnings, white for paused).
- **Device Input Mode**, which defines behavior of built-in power button of the device. *Device Input Mode* could be:
- *Disable button control*: built-in power button of the device is not used by Rayhunter.
- *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
- *Double-tap power button to start new recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
- **Automatically check for software updates** enables periodic checks against the Rayhunter GitHub releases page. When a newer release is found, the web UI shows a notice and, if ntfy update notifications are enabled, a notification is sent.
- **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
- **Enabled Notification Types** allows enabling or disabling the following types of notifications:
- *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes.
- *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI.
- *Software Updates*, which will alert when a new Rayhunter release is available. Only triggers when *Automatically check for software updates* is enabled.
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well.
## GPS
The **GPS Settings** allows you to attach GPS-based location history to every recording. Data is stored as a separate JSON file next to QMDL, and also inlined into the PCAP file as packet comment.
The modes are:
- *Disabled*, the default option, disables this feature entirely.
- *Fixed*, for hardcoding latitude (-90 to 90) and longitude (-180 to 180) for devices that don't move very often or at all. Every packet in the recording will have that location.
- *API Endpoint*, enables the `POST /api/gps` endpoint so that third-party tools (i.e. your own scripts) can update location info continuously. Please refer to the [API documentation](api-docs.md) for more info.
The GPS data is stored as a separate JSON file next to QMDL captures, and contains its own timestamps. These timestamps are meant to be compared during analysis with the packet timestamp so we know the time difference between the packet capture from the GPS capture, if there is any, since GPS data and packet data may come from two entirely separate devices.
## WiFi Client Mode
On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network.
- **Enable WiFi** turns WiFi client mode on or off. Disabling it does not erase saved credentials.
- **Scan** searches for nearby networks. Select one from the dropdown, or type an SSID manually.
- **Password** is required for WPA/WPA2 networks. The password is stored separately from `config.toml` (in `wpa_sta.conf` on the device) and is never exposed through the API.
- **DNS Servers** lets you override the DNS servers used when connected. Defaults to `9.9.9.9` and `149.112.112.112` (Quad9) if not set.
After saving, the connection status will show **connecting**, **connected** (with the assigned IP address), or **failed** (with an error message). If the connection fails, check that the SSID and password are correct and that the network is in range.
### Crash Recovery
The WiFi kernel module (`wlan.ko`) can occasionally crash or unload, taking both the hotspot and client interfaces down with it. Rayhunter includes a watchdog that detects this and automatically reloads the module, restarts the hotspot, and reconnects to the configured network. During recovery the WiFi status will show **recovering**.
On the first detection of a crash, a diagnostic snapshot is saved to `/data/rayhunter/crash-logs/` on the device. You can pull these logs with `adb pull /data/rayhunter/crash-logs/` and inspect them to understand what went wrong. Each log contains:
- **dmesg** output (kernel messages). Look for backtraces, `BUG:`/`Oops:` lines, or `wlan`/`wcnss` errors. The kernel ring buffer is small and gets overwritten quickly, so crash details may already be gone if the crash happened well before detection.
- **/proc/modules** snapshot. If `wlan` is absent, the module fully unloaded. If present but interfaces are gone, the driver is stuck.
- **ip addr** output confirming which network interfaces existed at snapshot time.
- **ps** output showing which WiFi-related processes (`hostapd`, `wpa_supplicant`, `wland`) were still running.
If recovery fails after 5 attempts, the status will change to **failed**. A reboot of the device will reset WiFi.
You can also configure WiFi during installation:
```sh
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
```
## WebDAV Upload
Rayhunter can automatically upload finished recordings to a WebDAV server. When a `[webdav]` section is present in `config.toml`, a background worker periodically scans the recording store and uploads any closed entry that is older than `min_age_secs`. Each eligible entry uploads two files: the raw `.qmdl` capture and its `.ndjson` analysis output. After a successful upload the entry is either marked as uploaded in the manifest (and skipped on subsequent polls), or deleted locally if `delete_on_upload = true`. With no `[webdav]` section, no upload worker runs.
WebDAV upload is currently configurable only by editing `config.toml` — there is no web UI control for it yet.
| Key | Required | Default | Description |
| --- | --- | --- | --- |
| `url` | yes | — | WebDAV server base URL, e.g. `https://example.com/remote.php/files/user/rayhunter/` |
| `username` | no | — | HTTP Basic auth username |
| `password` | no | — | HTTP Basic auth password |
| `upload_timeout_secs` | no | `300` | Timeout (seconds) for each upload request |
| `poll_interval_secs` | no | `3600` | How often (seconds) the worker scans for eligible entries |
| `min_age_secs` | no | `86400` | Minimum age (seconds) an entry must have before it becomes eligible for upload |
| `delete_on_upload` | no | `false` | Delete the entry locally after a successful upload |
Example:
```toml
[webdav]
url = "https://dav.example.com/rayhunter/"
username = "user"
password = "pass"
upload_timeout_secs = 300
poll_interval_secs = 3600
min_age_secs = 86400
delete_on_upload = false
```
A few notes on behavior:
- **Auth:** HTTP Basic. Supplying a `password` without a `username` is rejected — the request is sent unauthenticated and a warning is logged.
- **Retries and overwrites:** each entry's two files (`.qmdl` and `.ndjson`) must both upload successfully before the entry is marked as uploaded in the manifest. If one upload fails, the entry stays unmarked and both files are retried on the next poll — the one that previously succeeded will be overwritten on the server. Once an entry is marked as uploaded, Rayhunter will not upload it again.
- **Currently-recording entry:** the active recording is never uploaded; only closed entries are eligible.
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

6
doc/custom.css Normal file
View File

@@ -0,0 +1,6 @@
.warning-box {
padding: 0.75em 1em;
border-left: 4px solid #e33;
border-radius: 4px;
background-color: color-mix(in srgb, currentColor 10%, transparent);
}

View File

@@ -22,37 +22,60 @@ Please note that this file may contain sensitive information such as your IMSI a
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear which devices are locked nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. So far most verizon branded orbic devices we have encountered are actually unlocked.
### I can't reach my Rayhunter's web UI after leaving it alone for a while
Some hotspots (notably the T-Mobile TMOHS1 and Wingtech CT2MHS01) shut down their Wi-Fi access point after about 10 minutes with no connected clients to save battery. Rayhunter is still recording in the background, but you won't be able to reach the web UI until you power cycle the device or reconnect a client while Wi-Fi is still up.
To avoid this, set Wi-Fi Standby to "Always on" in the hotspot's native admin UI. See [TMOHS1](./tmobile-tmohs1.md#wi-fi-auto-shutdown) or [CT2MHS01](./wingtech-ct2mhs01.md#wi-fi-auto-shutdown) for step-by-step instructions.
### How do I re-enable USB tethering after installing Rayhunter?
Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands:
If you have installed with `./installer orbic-usb`, you might find that USB
tethering is now disabled. If you have run `./installer orbic`, this section is not
relevant as it does not use or touch USB.
[First obtain a shell](./orbic.md#shell), then:
```sh
./installer util shell "echo 9 > /usrdata/mode.cfg"
./installer util shell reboot
# inside of Orbic's shell:
echo 9 > /usrdata/mode.cfg
reboot
```
Make sure USB tethering is also enabled in the Orbic's UI.
To disable tethering again:
```sh
./installer util shell "echo 3 > /usrdata/mode.cfg"
./installer util shell reboot
# inside of Orbic's shell:
echo 3 > /usrdata/mode.cfg
reboot
```
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
### How do I connect my device to an existing WiFi network?
The Orbic, Moxee, UZ801, and TMOHS1 can connect to a nearby WiFi network while still running their own hotspot. This gives the device internet access for ntfy notifications and lets you reach the web UI from your home network. See [WiFi Client Mode](./configuration.md#wifi-client-mode) in the configuration guide for setup instructions.
### WiFi client mode is connected but I can't reach the internet
Check that the **DNS Servers** field in the config has valid entries (the default is `9.9.9.9` and `149.112.112.112`). If your home network and the device hotspot use the same subnet (for example, both are on `192.168.1.x`), try restarting the daemon by saving the config again from the web UI.
### How do I disable the WiFi hotspot on the Orbic RC400L?
To disable both WiFi bands:
To disable both WiFi bands, [first obtain a shell](./orbic.md#shell), then:
```sh
adb shell
/bin/rootshell -c "sed -i 's/<wlan><Feature><state>1<\/state>/<wlan><Feature><state>0<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot"
# inside of Orbic's shell:
sed -i 's/<wlan><Feature><state>1<\/state>/<wlan><Feature><state>0<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot
```
To re-enable WiFi:
```sh
adb shell
/bin/rootshell -c "sed -i 's/<wlan><Feature><state>0<\/state>/<wlan><Feature><state>1<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot"
# inside of Orbic's shell:
sed -i 's/<wlan><Feature><state>0<\/state>/<wlan><Feature><state>1<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot
```

View File

@@ -39,7 +39,7 @@ This heuristic will also issue a notification every time your identity is sent t
This analyzer tests if a base station releases your device's connection and redirects your device to a 2G base station. This heuristic is useful, because some IMSI catchers may operate in a such way that they downgrade connection to 2G where they can intercept the communication (by performing man-in-the-middle attack).
### LTE SIB6/7 Downgrade
### LTE SIB6/7 Downgrade (v2)
This analyzer tests if LTE base station is broadcasting a SIB type 6 and 7 messages which include 2G/3G frequencies with higher priorities.
@@ -49,7 +49,7 @@ This attack exploits the fact that SIB broadcast messages are not encrypted or a
SIB6 is used for cell reselection to CDMA2000 systems which are not supported by many modern mobile phones, and SIB7 Provides the mobile device with information to perform cell reselection to GSM/EDGE networks. Therefore SIB6 messages are quite rare, while malformed SIB7 messages are much more frequent in practice.
This heuristic is the most useful in the United States or other countries where there are no more operating 2G base stations. See [Wikipedia page on past 2G networks](https://en.wikipedia.org/wiki/2G#Past_2G_networks) for information about your country. In countries where 2G is still in service (such as most of EU), this heuristic may trigger false positives. In that case you should consider disabling it. However this heuristic has been vastly improved to reduce false positive warnings and new tests in European networks show that false positives are vastly reduced.
This heuristic is useful even in countries where 2g is still prevalent. A well behaved tower should always advertise its other 4g neighbors at a higher priority than 2g/3g neighbors. (Older versions of this heuristic were prone to false positives.)
### Null Cipher
@@ -73,6 +73,9 @@ This analyzer tests whether the SIB1 message contains a complete SIB chain (SIB3
On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **IMSI Requested** detection it should be considered as a strong indicator of malicious activity.
### Diagnostic Information
This analyzer displays some diagnostic information about when your device connects and disconnects from certain towers. It is helpful for analysis of suspicious PCAPs. The informational warnings in here can safely be ignored until there is a low, medium, or high severity warning.
### Test Analyzer
This analyzer is great for testing if your Rayhunter installation works. It will alert every time a new tower is seen (specifically every time a tower broadcasts a SIB1 message.) It is designed to be very noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working!

View File

@@ -3,5 +3,8 @@
So, you've got one of the [supported devices](./supported-devices.md), and are ready to start catching IMSI catchers. You have two options for installing Rayhunter:
* [installing from a release (recommended)](./installing-from-release.md)
* [installing from a release on Windows](./installing-from-release-windows.md)
* [installing from source](./installing-from-source.md)
Already have Rayhunter installed but looking to update?
* [Updating Rayhunter](./updating-rayhunter.md)

View File

@@ -41,23 +41,21 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
```bash
# For Orbic:
./installer orbic --admin-password 'mypassword'
# Or install over USB if you want ADB and a root shell (not recommended for most users)
./installer orbic-usb
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
# may be required if different from the default.
# For TP-Link:
./installer tplink
```
* On Verizon Orbic, the password is the WiFi password.
* On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g`
* On Moxee-brand devices, check under the battery for the password.
* You can reset the password by pressing the button under the back case until the unit restarts.
TP-Link does not require an `--admin-password` parameter.
* On Verizon Orbic, the default password is the WiFi password. You can reset the password by pressing the button under the back case until the unit restarts.
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
* TP-Link does not require an `--admin-password` parameter.
For other devices, check `./installer --help` or the
respective page in the sidebar under "Supported
Devices."
Devices" for the installation command.
7. The installer will eventually tell you it's done, and the device will reboot.

View File

@@ -1,64 +1,113 @@
# Installing from source
Building Rayhunter from source, either for development or because the install script doesn't work on your system, involves a number of external dependencies. Unless you need to do this, we recommend you use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
Building Rayhunter from source, either for development or otherwise, involves a
number of external dependencies. Unless you need to do this, we recommend you
use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI
* Make sure to build the site with `pushd daemon/web && npm install && npm run build && popd` before building Rayhunter. If you're working directly on the frontend, `npm run dev` will allow you to test a local frontend with hot-reloading (use `http://localhost:5173` instead of `http://localhost:8080`).
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
* Install `curl` on your computer to run the install scripts. It is not needed to build binaries.
At a high level, we have:
### Install Rust targets
* A JS frontend written in SvelteKit (`./daemon/web/`)
* A Rust binary `rayhunter-daemon` (`./daemon/`) that runs on the device, and bundles the frontend.
* A Rust binary `installer` (`./installer`) that runs on the computer and bundles `rayhunter-daemon`.
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then,
It's recommended to work either on Mac/Linux, or WSL on Windows.
- install the cross-compilation target for the device Rayhunter will run on:
```sh
rustup target add armv7-unknown-linux-musleabihf
```
## Building frontend and backend
- install the statically compiled target for your host machine to build the binary installer `serial`.
```sh
# check which toolchain you have installed by default with
rustup show
# now install the correct variant for your host platform, one of:
rustup target add aarch64-unknown-linux-musl
rustup target add armv7-unknown-linux-musleabi
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
rustup target add x86_64-pc-windows-gnu
```
First, install dependencies:
Now you can root your device and install Rayhunter by running:
- [Rust](https://www.rust-lang.org/tools/install)
- [Node.js/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- C compiler tools (`apt install build-essential` on Linux, `xcode-select --install` on Mac)
Then you can build everything with:
```sh
# Build the daemon binary for local development (rustcrypto TLS backend, fast compilation)
# WARNING: The rustcrypto library, though not known to be insecure, is less well
# tested than its counterpart and could potentially have severe issues in
# its cryptographic implementation. We therefore recommend using ring-tls in
# production builds (see below)
cargo build-daemon-firmware-devel
# To build it exactly like in CI (more mature ring TLS backend, slower compilation)
# CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
# Build rootshell
cargo build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware
# Replace 'orbic' with your device type if different.
# A list of possible values can be found with 'cargo run --bin installer help'.
# Use FILE_RAYHUNTER_DAEMON to specify the daemon binary path when using development builds:
FILE_RAYHUNTER_DAEMON=$PWD/target/armv7-unknown-linux-musleabihf/firmware-devel/rayhunter-daemon cargo run -p installer --bin installer orbic
./scripts/build-dev.sh
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
```
### If you're on Windows or can't run the install scripts
## Running the daemon on your PC
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183>
* Build the web UI using `cd daemon/web && npm install && npm run build`
* Push the scripts in `scripts/` to `/etc/init.d` on device and make a directory called `/data/rayhunter` using `adb shell` (and sshell for your root shell if you followed the steps above)
* You also need to copy `config.toml.in` to `/data/rayhunter/config.toml`. Uncomment the `device` line and set the value to your device type if necessary.
* Then run `./make.sh`, which will build the binary, push it over adb, and restart the device. Once it's restarted, Rayhunter should be running!
If you don't have a target device handy, you can run `rayhunter-daemon` on your
PC with `debug_mode = true`. This skips DIAG, the device display, key input,
the battery worker, and the WiFi client, so recording-related endpoints will
not work, but the frontend and read-only APIs do.
```sh
mkdir -p ./qmdl && printf 'entries = []\n' > ./qmdl/manifest.toml
cat > config.toml <<'EOF'
qmdl_store_path = "./qmdl"
port = 8080
debug_mode = true
EOF
cargo run -p rayhunter-daemon -- ./config.toml
```
Open `http://127.0.0.1:8080`.
## Hot-reloading the frontend
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
You can instead run the frontend separately on your PC while the Rust parts
continue running on your target device:
```sh
cd daemon/web
# Assumes rayhunter-daemon is listening on localhost:8080
npm run dev
# Use a custom target IP:port where the backend runs
API_TARGET=http://192.168.1.1:8080 npm run dev
```
The UI will listen on `localhost:5173` and instantly show any frontend changes
you make. Backend changes require building everything from the top (daemon and installer).
## Installer utils, getting a shell
Check `./scripts/install-dev.sh util --help`
for useful utilities for transferring files, opening shells. The exact tools
available wildly depend on the device you're working on, and they are
usually documented the relevant device's page under [Supported
Devices](./supported-devices.md).
A lot of devices run a trimmed down version of Android and have ADB (Android
Debug Bridge) support. The USB-based installers (`orbic-usb`, `pinephone`,
`uz801`) use ADB to perform the installation.
You might want to install and use actual ADB to connect to the device, push
files and generally poke around. The installer contains some tools to enable ADB:
```sh
adb kill-server
# Enables ADB on either of these devices
./scripts/install-dev.sh util tmobile-start-adb
./scripts/install-dev.sh orbic-usb
adb shell
```
Note though that we can't assist with any issues setting ADB up, _especially
not_ on Windows. There have been too many driver issues to make this the
"golden path" for most users or contributors. There have been instances where
people managed to brick their orbic devices using ADB on Windows.
## Troubleshooting
You may need to turn off your VPN in order to load the frontend succesfully - even with local network sharing enabled, VPNs can interfere with the connection to the backend.
Specifically for WSL users:
- The HyperV firewall also tends to interfere with the connection between frontend and backend. You can turn it off in your WSL settings.
- WSL2 has a known compatibility issue which may prevent vite from detecting file system changes and therefore affects HMR (hot module replacement).
If your hot reloading does not work, some have success using polling to detect changes. To do so, specify the following setting in vite.config.ts:
```ts
server: {
watch: { usePolling: true }
}
```

View File

@@ -5,6 +5,8 @@ Supported in Rayhunter since version 0.6.0.
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
primarily for the US market.
**These devices have relatively little storage. The Orbic is usually a better alternative, though might be more expensive.**
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
- [Moxee product page](https://www.moxee.com/hotspot)
@@ -25,18 +27,30 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL),
| 66 | 1700/2100 MHz (E-AWS) |
| 71 | 600 MHz |
## Installation
## Installing
Connect to the hotspot's network using WiFi or USB tethering and run:
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
```sh
./installer orbic-network --admin-password 'mypassword'
```bash
./installer moxee --admin-password 'mypassword'
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
# may be required if different from the default.
```
The password (in place of `mypassword`) is under the battery.
* The password is the one used to log in to the device's admin menu. You can reset the password by pressing the button under the back case until the unit restarts.
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
* On Moxee-brand devices, check under the battery for the password.
* `./installer moxee` is almost the same as `./installer orbic`, it just comes with slightly better defaults that will give you more space for recordings.
The Rayhunter UI will be available at <http://192.168.1.1:8080>.
<a name=shell></a>
## Obtaining a shell
```sh
./installer util orbic-start-telnet
After running the installer, there will not be a root shell and ADB will not be
enabled. Instead, you can use:
```bash
./installer util orbic-shell
```

View File

@@ -6,7 +6,8 @@ It is also sometimes sold under the brand Kajeet RC400L. This is the exact same
You can buy an Orbic [using bezos
bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l). You should not
pay more than 30 USD for such a device (without shipping).
[Please check whether the Orbic works in your country](https://www.frequencycheck.com/countries/), and whether the Orbic RC400L supports the right frequency bands for your purpose before buying.
@@ -21,6 +22,10 @@ or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
| Wifi 5Ghz | a/ac/ax |
| Wifi 6 | 🮱 |
## WiFi client mode
The Orbic's QCA6174 radio supports running the hotspot and connecting to an external WiFi network at the same time. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for setup.
## Two kinds of installers
The orbic's installation routine underwent many different changes:
@@ -37,12 +42,41 @@ The orbic's installation routine underwent many different changes:
It's possible that many tutorials out there still refer to some of the old
installation routines.
## Installing
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
```bash
# For Orbic:
./installer orbic --admin-password 'mypassword'
# For Kajeet/Smartspot:
./installer orbic --admin-password '$m@rt$p0tc0nf!g'
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
# may be required if different from the default.
# Or install over USB if you want ADB and a root shell (not recommended for most users)
./installer orbic-usb
```
* The password is the one used to log in to the device's admin menu. You can reset the password by pressing the button under the back case until the unit restarts.
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
* On Verizon Orbic, the default password is the WiFi password.
* On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g`
The Rayhunter UI will be available at <http://192.168.1.1:8080>.
<a name=shell></a>
## Obtaining a shell
After running the installer, there will not be a rootshell and ADB will not be
enabled. Instead you can use `./installer util orbic-start-telnet` and connect
to the hotspot using `nc 192.168.1.1 24`. On Windows you might not have `nc`
and will have to use WSL for that.
After running the installer, there will not be a root shell and ADB will not be
enabled. Instead, you can use:
```bash
./installer util orbic-shell
```
If you are using an installer prior to 0.7.0 or `orbic-usb` explicitly, you can
obtain a root shell by running `adb shell` or `./installer util shell`. Then,

88
doc/porting.md Normal file
View File

@@ -0,0 +1,88 @@
# Porting to new devices
## When will we consider new devices?
Rayhunter is already officially supported on [several devices](./supported-devices.md), and people are often interested in adding support for hardware they already own. Here's a non-exhaustive list of situations where we'd consider adding a new Tier 2 device:
* The device is significantly cheaper or more available in a specific region than any device we already support.
* The device supports 5G and costs less than 100 USD.
* You're willing to commit to supporting this device and handling bug reports.
* The device has support for all cellular bands and can work in any country.
We want to avoid a situation where the list of supported devices keeps growing but the number of recurring contributors and maintainers stays the same.
That said, you can always maintain a fork, or install Rayhunter manually without writing an installer. You can promote this work in the [GitHub discussions](https://github.com/EFForg/rayhunter/discussions) area, where most new hardware investigations happen.
Please don't open issues about supporting a new device, use GitHub discussions instead. Most hardware investigations end up being abandoned, and the amount of issues we'd have to triage would be too much.
## Prerequisites: root shell, and /dev/diag
Rayhunter is a Linux binary that reads traffic from the Qualcomm diagnostic interface, which requires root. If either of those isn't available, Rayhunter can't work. Everything else (displays, buttons) is secondary, and we can deal with it later.
In the devices we currently support `/dev/diag` is the interface for Qualcomm diagnostics and devices with this will be easiest to support. Newer Qualcomm modems expose the diagnostic interface over a USB gadget which is something we are working on support for, but do not currently have. Thus devices with the former diagnostic interface will be easier to port Rayhunter to.
You can check ahead of purchase whether `/dev/diag` is available by ensuring the device has a Qualcomm MDM* chip. Other Qualcomm LTE chips might work but we haven't encountered one yet. Typically you will be able to get this information from [fcc.report](https://fcc.report), where either the chip is written down in some PDF or at least plainly visible in one of the teardown photos. Sometimes this information can also be found through teardown videos on YouTube. If you find that chip, there's a good chance (but no guarantee) `/dev/diag` is available.
Any vendor other than Qualcomm (Mediatek, Rockchip, ...) is unlikely to work. Quectel sometimes repackages Qualcomm chips into larger systems and might work. Huawei devices won't work, as they use their own chips.
Getting a root shell varies from device to device. Check the [GitHub discussions](https://github.com/EFForg/rayhunter/discussions) for prior art, and look through the installer source in `installer/src/` for inspiration. These approaches are common:
* Connecting with `adb shell`.
* If `adb shell` doesn't work, sending a special USB serial command might enable it.
* Sometimes there's an unpatched CVE that can be used to launch `telnetd` as root (search "device name CVE", the website [opencve.io](https://opencve.io) is particularly easy to use).
Once you have a root shell, check that `/dev/diag` exists.
## Installing Rayhunter manually
The Rayhunter installation consists of just two components: the `rayhunter-daemon` binary, and the config file (`config.toml`).
Typically the layout on the filesystem will look like this:
```text
/data/rayhunter/rayhunter-daemon
/data/rayhunter/config.toml
/data/rayhunter/qmdl/
```
Then, `./rayhunter-daemon config.toml` can be started manually.
You can refer to [Installing from source](./installing-from-source.md) for how to obtain the `rayhunter-daemon` binary.
We're assuming that your device is ARMv7, i.e. 32-bit ARM (`armv7-unknown-linux-musleabihf`). If that's not the case, you can still build the daemon but you'll need to figure out the correct target triple on your own.
You can copy the daemon and config files to the device using `netcat` or `adb push`. They don't have to be in `/data/rayhunter/`, this is just convention. If you use a different path, be sure to update the `qmdl_store_path` setting in `config.toml`.
The `device` setting in `config.toml` must match one of the lowercase variant names from the `Device` enum (e.g. `"orbic"`, `"tplink"`). This controls which display driver is used.
To bring the daemon up without `/dev/diag` (for instance, to test the display and web UI before the hardware path works), see [Running the daemon on your PC](./installing-from-source.md#running-the-daemon-on-your-pc).
### Autostart
To make Rayhunter start on boot, you'll need an init script. The existing installers use the template at `dist/scripts/rayhunter_daemon`, which has a `#RAYHUNTER-PRESTART` placeholder that gets replaced with device-specific setup commands (e.g. killing a vendor UI process, mounting an SD card). Look at how the existing installers handle this in their `install()` functions.
## Display support
The `device` setting [mentioned above](#installing-rayhunter-manually) also controls which display driver is loaded (see [`Device` enum in `lib/src/lib.rs`](https://github.com/EFForg/rayhunter/blob/main/lib/src/lib.rs)). Unless your device is a variant of an existing device, you'll want to add a new variant to the `Device` enum and write a corresponding display module in `daemon/src/display/`.
You can play around with the existing values of the `device` setting to see which one ends up rendering on your device's display. Most likely your device has a display similar enough to an existing one, and the display module for that device (e.g. `daemon/src/display/orbic.rs`, `daemon/src/display/tplink.rs`) can be used as a starting point.
If your device has LEDs instead of a display, take a look at `daemon/src/display/uz801.rs` which controls LEDs via sysfs.
## Button support
Rayhunter can use the power button to restart recordings via a double-tap gesture. The implementation is in [`daemon/src/key_input.rs`](https://github.com/EFForg/rayhunter/blob/main/daemon/src/key_input.rs). It currently has no structure for device-specific implementations, as all devices we support expose the same input event interface.
The `key_input_mode` setting in `config.toml` controls this feature (`0` = disabled, `1` = double-tap power button to start/stop recordings).
## Writing the installer, and contributing official support
At this point you'll want to have figured out how to automate the entire installation in principle, and how to make it as repeatable as possible. A proof-of-concept of this in bash or another language is also a welcome contribution (to be posted on [GitHub discussions](https://github.com/EFForg/rayhunter/discussions), not as a PR).
Writing the installer means adding a new variant to the `Command` enum in [`installer/src/lib.rs`](https://github.com/EFForg/rayhunter/blob/main/installer/src/lib.rs) and implementing the install logic in a new module under `installer/src/`. Each subcommand maps to a device-specific entry point function (e.g. `tplink::main_tplink`, `orbic_network::install`).
The installer gets the daemon binary path from `env!("FILE_RAYHUNTER_DAEMON")`, which is set at build time. Config installation is handled by the shared `install_config()` helper in the `connection` module, which writes the config file with the correct device name.
You must also add a shell utility subcommand under `installer util` (the `UtilSubCommand` enum in `installer/src/lib.rs`), e.g. `installer util tplink-shell`, `installer util orbic-shell`. This is required -- without it, users and developers have no way to interactively debug the device. Depending on connectivity, this might be a telnet session, an ADB shell, or a serial connection. Other utilities (file transfer helpers, etc.) are optional but encouraged. See the existing `UtilSubCommand` variants for examples.
Please reuse existing utilities wherever possible. Take a look at [`installer/src/tplink.rs`](https://github.com/EFForg/rayhunter/blob/main/installer/src/tplink.rs) and [`installer/src/orbic_network.rs`](https://github.com/EFForg/rayhunter/blob/main/installer/src/orbic_network.rs) for inspiration. But the structures there are still evolving, and we'll happily guide you during code review.

View File

@@ -2,7 +2,15 @@
If you're using Rayhunter (or trying to), we'd love to hear from you! Check out one of the following forums for contacting the Rayhunter developers and community:
* If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (QMDL and PCAP logs) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
* If you're having issues installing or using Rayhunter, please [open an issue](https://github.com/EFForg/rayhunter/issues) on our Github repo.
* If you've received a Rayhunter warning, please send your Rayhunter data captures (the ZIP file) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version.
Note that the recording files are sensitive data and contain location
information, so we strongly recommend against posting them to publicly.
If you're unfamiliar with Signal, feel free to check out our [Security Self
Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
* If you're having issues installing or using Rayhunter, consider checking the [Frequently Asked Questions](./faq.md) page for answers to common questions.
* If your question isn't answered there, please [open an issue](https://github.com/EFForg/rayhunter/issues) on our Github repo.
* If you'd like to propose a feature, heuristic, or device for Rayhunter, [start a discussion](https://github.com/EFForg/rayhunter/discussions) in our Github repo
* For anything else, join us in the `#rayhunter` or `#rayhunter-developers` channel of [EFF's Mattermost](https://opensource.eff.org/signup_user_complete/?id=r1b6cnta9bysxk6im3kuabiu1y&md=link&sbr=su) instance to chat!

View File

@@ -30,4 +30,4 @@ Rayhunter is confirmed to work on these devices.
## Adding new devices
Rayhunter was built and tested primarily on the Orbic RC400L mobile hotspot, but the community has been working hard at adding support for other devices. Theoretically, if a device runs a Qualcomm modem and exposes a `/dev/diag` interface, Rayhunter may work on it.
If you have a device in mind which you'd like Rayhunter to support, please [open a discussion on our Github](https://github.com/EFForg/rayhunter/discussions)!
If you have a device in mind which you'd like Rayhunter to support, please read the [porting guide](./porting.md) and [open a discussion on our Github](https://github.com/EFForg/rayhunter/discussions)!

28
doc/swagger-ui.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: './rayhunter-openapi.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout",
});
};
</script>
</body>
</html>

View File

@@ -36,6 +36,10 @@ According to FCC ID 2APXW-TMOHS1 Test Report No. I20Z61602-WMD02 ([part 1](https
| 66 | 1700 MHz (E-AWS) |
| 71 | 600 MHz (USDD) |
## WiFi client mode
The TMOHS1 supports WiFi client mode, allowing Rayhunter to connect to an existing WiFi network while keeping the hotspot running. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for setup.
## Installing
Connect to the TMOHS1's network over WiFi or USB tethering.
@@ -55,6 +59,21 @@ Then run the installer:
| Paused | WiFi LED blinks white. |
| Warning Detected | Signal LED slowly blinks red. |
## Wi-Fi auto-shutdown
By default the TMOHS1 turns off its Wi-Fi access point after 10 minutes with no connected clients. Rayhunter keeps recording on the device in the background, but once the access point is down you can't reach the web UI, download captures, or see new warnings until you power cycle the hotspot.
The TMOHS1's native admin UI lets you change this:
1. Connect to the TMOHS1's Wi-Fi (or USB tether).
2. In a browser open `http://192.168.0.1/` and log in with the admin password.
3. Go to **Settings****Sleep****Wi-Fi Standby** and pick **Always on**.
4. Click **Apply**.
![TMOHS1 Wi-Fi Standby setting](./tmohs1-wifi-standby.png)
Keeping Wi-Fi always on uses more battery. If you only monitor Rayhunter through the device's LEDs and don't need remote access, the default 10-minute timer is fine.
## Obtaining a shell
Even when rayhunter is running, for security reasons the TMOHS1 will not have telnet or adb enabled during normal operation.

BIN
doc/tmohs1-wifi-standby.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -18,7 +18,8 @@ The TP-Link M7350 supports many more frequency bands than Orbic and therefore wo
The TP-Link comes in many different *hardware versions*. Support for installation varies:
* `1.0`, `2.0`: **Not supported**, devs are not able to obtain a device
* `1.0`, Confirmed working. Successfully tested by a user with the Windows installer (rayhunter-v0.10.2-windows-x86_64). Ensure the SD card is formatted as FAT32 before installation.
* `2.0`: **Not supported**, devs are not able to obtain a device
* `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: **Tested, no known issues since 0.3.0.**
* `6.2`: **One user reported it is working, not tested**
* `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332))
@@ -35,18 +36,25 @@ You can get your TP-Link M7350 from:
* First check for used offers on local sites, sometimes it's much cheaper there.
* [Geizhals price comparison](https://geizhals.eu/?fs=tp-link+m7350).
* [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313).
* Can also be found sold as the 'Vodafone Pocket Wifi 5' in Australia
## Installation & Usage
## Installing
Follow the [release installation guide](./installing-from-release.md). Substitute `./installer orbic` for `./installer tplink` in other documentation. The Rayhunter UI will be available at <http://192.168.0.1:8080>.
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
```bash
./installer tplink
```
The Rayhunter UI will be available at <http://192.168.0.1:8080>.
<a name=shell></a>
## Obtaining a shell
Unlike on Orbic, the installer will not enable ADB. Instead, you can obtain a root shell with the following command:
You can obtain a root shell with the following command:
```sh
./installer util tplink-start-telnet
telnet 192.168.0.1
./installer util tplink-shell
```
## Display states
@@ -70,7 +78,7 @@ On hardware revisions starting with v4.0, the installer will modify settings to
add two port triggers. You can look at `Settings > NAT Settings > Port
Triggers` in TP-Link's admin UI to see them.
1. One port trigger "rayhunter-root" to launch the telnet shell. This is only needed for installation, and can be removed after upgrade. You can reinstall it using `./installer util tplink-start-telnet`.
1. One port trigger "rayhunter-root" to launch the telnet shell. This is only needed for installation, and can be removed after upgrade. You can reinstall it using `./installer util tplink-shell`.
2. One port trigger "rayhunter-daemon" to auto-start Rayhunter on boot. If you remove this, Rayhunter will have to be started manually from shell.
## Other links

View File

@@ -1,23 +1,25 @@
# Uninstalling
There is no automated uninstallation routine, so this page documents the routine for some devices.
## Orbic
To uninstall Rayhunter, power on your Orbic device and connect to it via USB. Then, start a rootshell on it by running `adb shell`, followed by `rootshell`.
Run `./installer util orbic-shell --admin-password mypassword`. Refer to the
installation instructions for how to find out the admin password.
Once in a rootshell, run:
Inside, run:
```shell
echo 3 > /usrdata/mode.cfg
echo 3 > /usrdata/mode.cfg # only relevant if you previously installed via ADB installer
rm -rf /data/rayhunter /etc/init.d/rayhunter_daemon /bin/rootshell
reboot
```
Your device is now Rayhunter-free, and should no longer be in a rooted ADB-enabled mode.
Your device is now Rayhunter-free, and should no longer be rooted.
## TPLink
1. Run `./installer util tplink-start-telnet`
2. Telnet into the device `telnet 192.168.0.1`
1. Run `./installer util tplink-shell` to obtain rootshell on the device.
3. `rm /data/rayhunter /etc/init.d/rayhunter_daemon`
4. `update-rc.d rayhunter_daemon remove`
5. (hardware revision v4.0+ only) In `Settings > NAT Settings > Port Triggers` in TP-Link's admin UI, remove any leftover port triggers.

View File

@@ -19,6 +19,8 @@ You can access this UI in one of two ways:
On the **Orbic**, you can find the WiFi network password by going to the Orbic's menu > 2.4 GHz WIFI Info > Enter > find the 8-character password next to the lock 🔒 icon.
On the **TP-Link**, you can find the WiFi network password by going to the TP-Link's menu > Advanced > Wireless > Basic Settings.
If [WiFi client mode](./configuration.md#wifi-client-mode) is enabled, you can also reach the web UI from any device on that network at `http://<device-ip>:8080`.
* **Connect over USB (Orbic):** Connect your device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit <http://localhost:8080>.
* For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the `releases/platform-tools/` folder to somewhere else in your path or you can install it manually.
* You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).
@@ -26,6 +28,8 @@ You can access this UI in one of two ways:
* **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you.
> **_NOTE:_** When downloading recordings, "Insecure download blocked" warnings can safely be ignored - this is due to Rayhunter not using HTTPS.
## Key shortcuts
As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).

View File

@@ -36,6 +36,12 @@ With the device fully booted (i.e. beaming a WiFi network, blue LED, etc.) and p
Note: The default IP for UZ801 is typically `192.168.100.1`; if yours differs, use the `--admin-ip` argument to specify it.
## WiFi client mode
The UZ801's WCN36xx (PRONTO) radio supports concurrent AP+STA mode. The daemon has backend support for WiFi client mode on the UZ801, but this has not yet been successfully exercised end-to-end and the web UI currently does not expose the configuration surface on this device. Treat UZ801 WiFi client mode as not yet supported. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for the intended setup on supported devices.
The interface creation method differs from the Orbic (which uses `iw`): the UZ801 creates a P2P_CLIENT virtual interface via nl80211 and converts it to a managed STATION interface. This is handled by the daemon when the feature is enabled.
## LED modes
| Rayhunter state | LED indicator |
| ---------------- | ------------------- |

View File

@@ -28,6 +28,10 @@ Wingtechs are abundant on ebay and can also be found on Amazon:
- <https://www.ebay.com/itm/127147132518>
- <https://www.amazon.com/AT-Turbo-Hotspot-256-Black/dp/B09YWLXVWT>
## WiFi client mode
The Wingtech supports WiFi client mode, allowing Rayhunter to connect to an existing WiFi network while keeping the hotspot running. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for setup.
## Installing
Connect to the Wingtech's network over WiFi or USB tethering, then run the installer:
@@ -50,6 +54,21 @@ telnet 192.168.1.1
adb shell
```
## Wi-Fi auto-shutdown
By default the CT2MHS01 turns off its Wi-Fi access point after the configured sleep timer (default 10 minutes) with no connected clients. Rayhunter keeps recording on the device in the background, but once the access point is down you can't reach the web UI, download captures, or see new warnings until you power cycle the hotspot.
The CT2MHS01's native admin UI lets you change this:
1. Connect to the Wingtech's Wi-Fi (or USB tether).
2. In a browser open `http://192.168.1.1/` and log in with the admin password.
3. Go to **Settings****Sleep****Wi-Fi Standby** and pick **Always on**.
4. Click **Save**.
![CT2MHS01 Wi-Fi Standby setting](./ct2mhs01-wifi-standby.png)
Keeping Wi-Fi always on uses more battery. If you primarily monitor Rayhunter through the device's screen and don't need remote access, leave the timer at its default.
## Developing
The device has a framebuffer-driven screen at /dev/fb0 that behaves
similarly to the Orbic RC400L, although the userspace program

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