Compare commits

...

71 Commits

Author SHA1 Message Date
Brad Warren fbd8110be9 remove another instance of outdated docs 2026-05-29 13:17:32 -07:00
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
85 changed files with 4736 additions and 3871 deletions
+16 -4
View File
@@ -1,3 +1,7 @@
# 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 version: 2
updates: updates:
# Rust dependencies # Rust dependencies
@@ -5,8 +9,10 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
@@ -15,8 +21,10 @@ updates:
directory: "/tools" directory: "/tools"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
@@ -25,8 +33,10 @@ updates:
directory: "/daemon/web" directory: "/daemon/web"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
@@ -35,7 +45,9 @@ updates:
directory: "/installer-gui" directory: "/installer-gui"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
+32 -13
View File
@@ -28,6 +28,7 @@ jobs:
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }} daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }} web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_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_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }} installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }} rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
@@ -41,11 +42,13 @@ jobs:
run: | run: |
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }} lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, if workflow/cargo config files changed, or if # We rebuild everything if any of these conditions hold:
# the latest commit message contains "#build-all", run everything. # * We are on main
# Use #build-all in a commit message to force a full build on a PR # * Changes are made to github workflows
# branch (useful for testing release builds without merging to main). # * A cargo-workspace file changed (lockfile or .cargo), as that could affect any crate anywhere
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo || git log -1 --format='%s %b' | grep -qF '#build-all' # * 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 then
echo "building everything" echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT" echo code_count=forced >> "$GITHUB_OUTPUT"
@@ -141,13 +144,13 @@ jobs:
npm install npm install
npm run build npm run build
popd popd
NO_FIRMWARE_BIN=true cargo check --verbose cargo check --verbose
- name: Run tests - name: Run tests
run: | run: |
NO_FIRMWARE_BIN=true cargo test --verbose cargo test --verbose
- name: Run clippy - name: Run clippy
run: | run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose cargo clippy --verbose
installer_gui_check: installer_gui_check:
# we test the GUI installer separately to: # we test the GUI installer separately to:
@@ -169,13 +172,16 @@ jobs:
with: with:
components: clippy components: clippy
- uses: Swatinem/rust-cache@v2 - 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 # 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 # fmt --all runs on all workspace packages so this is handled by
# check_and_test above # check_and_test above
- name: Check - name: Check
run: NO_FIRMWARE_BIN=true cargo check --package installer-gui --verbose run: cargo check --package installer-gui --verbose
- name: Run clippy - name: Run clippy
run: NO_FIRMWARE_BIN=true cargo clippy --package installer-gui --verbose run: cargo clippy --package installer-gui --verbose
test_daemon_frontend: test_daemon_frontend:
needs: files_changed needs: files_changed
@@ -227,12 +233,12 @@ jobs:
shell: bash shell: bash
run: | run: |
cd installer cd installer
NO_FIRMWARE_BIN=true cargo check --verbose cargo check --verbose
- name: cargo test - name: cargo test
shell: bash shell: bash
run: | run: |
cd installer cd installer
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features cargo test --verbose --no-default-features
build_rayhunter_check: build_rayhunter_check:
if: needs.files_changed.outputs.daemon_changed == 'true' if: needs.files_changed.outputs.daemon_changed == 'true'
@@ -272,6 +278,9 @@ jobs:
with: with:
targets: ${{ matrix.platform.target }} targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
cache-bin: 'false'
- name: Build rayhunter-check - name: Build rayhunter-check
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }} run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -305,7 +314,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
build_wpa_supplicant: build_wpa_supplicant:
if: needs.files_changed.outputs.installer_changed == 'true' if: needs.files_changed.outputs.installer_build == 'true'
needs: needs:
- files_changed - files_changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -408,6 +417,9 @@ jobs:
with: with:
targets: ${{ matrix.platform.target }} targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2 - 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 }} - run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
@@ -423,6 +435,7 @@ jobs:
needs: needs:
- build_rayhunter - build_rayhunter
- build_rootshell - build_rootshell
- build_wpa_supplicant
- files_changed - files_changed
- installer_gui_check - installer_gui_check
- test_installer_frontend - test_installer_frontend
@@ -479,6 +492,7 @@ jobs:
needs: needs:
- build_rayhunter - build_rayhunter
- build_rootshell - build_rootshell
- build_wpa_supplicant
- files_changed - files_changed
- installer_gui_check - installer_gui_check
- test_installer_frontend - test_installer_frontend
@@ -499,6 +513,10 @@ jobs:
with: with:
targets: ${{ matrix.platform.target }} targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2 - 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 - name: Build GUI installer
shell: bash shell: bash
run: | run: |
@@ -522,6 +540,7 @@ jobs:
needs: needs:
- build_rayhunter - build_rayhunter
- build_rootshell - build_rootshell
- build_wpa_supplicant
- files_changed - files_changed
- installer_gui_check - installer_gui_check
- test_installer_frontend - test_installer_frontend
+1 -1
View File
@@ -76,7 +76,7 @@ This one is for maintainers of Rayhunter.
1. Make a PR changing the versions in `Cargo.toml` and other files. 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: 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. 2. Merge PR and make a tag.
Generated
+515 -175
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter-check" name = "rayhunter-check"
version = "0.10.2" version = "0.11.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+2 -2
View File
@@ -155,10 +155,10 @@ async fn pcapify(qmdl_path: &PathBuf) {
.await .await
.expect("failed to get container") .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) { if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
pcap_writer pcap_writer
.write_gsmtap_message(parsed, timestamp) .write_gsmtap_message(parsed, timestamp, None)
.await .await
.expect("failed to write"); .expect("failed to write");
} }
+8 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.10.2" version = "0.11.2"
edition = "2024" edition = "2024"
rust-version = "1.88.0" rust-version = "1.88.0"
@@ -21,9 +21,10 @@ apidocs = ["dep:utoipa", "wifi-station/utoipa"]
[dependencies] [dependencies]
rayhunter = { path = "../lib" } rayhunter = { path = "../lib" }
wifi-station = { git = "https://github.com/BeigeBox/wifi-station", rev = "e8ec5b4" } wifi-station = "0.10.1"
toml = "0.8.8" toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] } serde = { version = "1.0.193", features = ["derive"] }
serde_repr = "0.1"
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] } tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
thiserror = "1.0.52" thiserror = "1.0.52"
@@ -40,9 +41,13 @@ image = { version = "0.25.1", default-features = false, features = ["png", "gif
tempfile = "3.10.2" tempfile = "3.10.2"
async_zip = { version = "0.0.17", features = ["tokio"] } async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98" anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false } reqwest = { version = "0.12.20", default-features = false, features = ["stream"] }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
rustls-post-quantum = { version = "0.2.4", optional = true } rustls-post-quantum = { version = "0.2.4", optional = true }
async-trait = "0.1.88" async-trait = "0.1.88"
utoipa = { version = "5.4.0", optional = true } utoipa = { version = "5.4.0", optional = true }
url = "2.5.4" url = "2.5.4"
[dev-dependencies]
reqwest = { version = "0.12.20", default-features = false, features = ["gzip"] }
tempfile = "3.10.2"
+4 -3
View File
@@ -18,7 +18,7 @@ use tokio::sync::mpsc::Receiver;
use tokio::sync::{RwLock, RwLockWriteGuard}; use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::{FileKind, RecordingStore};
use crate::server::ServerState; use crate::server::ServerState;
pub struct AnalysisWriter { pub struct AnalysisWriter {
@@ -145,9 +145,10 @@ async fn perform_analysis(
.await .await
.map_err(|e| format!("{e:?}"))?; .map_err(|e| format!("{e:?}"))?;
let qmdl_file = qmdl_store let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index) .open_file(entry_index, FileKind::Qmdl)
.await .await
.map_err(|e| format!("{e:?}"))?; .map_err(|e| format!("{e:?}"))?
.ok_or("QMDL file not found")?;
(analysis_file, qmdl_file) (analysis_file, qmdl_file)
}; };
+84 -10
View File
@@ -1,10 +1,40 @@
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use rayhunter::Device; use rayhunter::Device;
use rayhunter::analysis::analyzer::AnalyzerConfig; use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError; 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; use crate::notifications::NotificationType;
/// The structure of a valid rayhunter configuration /// The structure of a valid rayhunter configuration
@@ -21,21 +51,29 @@ pub struct Config {
/// Internal device name /// Internal device name
pub device: Device, pub device: Device,
/// UI level /// UI level
pub ui_level: u8, pub ui_level: UiLevel,
/// Colorblind mode /// Colorblind mode
pub colorblind_mode: bool, pub colorblind_mode: bool,
/// Key input mode /// Key input mode
pub key_input_mode: u8, pub key_input_mode: KeyInputMode,
/// ntfy.sh URL /// ntfy.sh URL
pub ntfy_url: Option<String>, pub ntfy_url: Option<String>,
/// Vector containing the types of enabled notifications /// Vector containing the types of enabled notifications
pub enabled_notifications: Vec<NotificationType>, 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 /// Vector containing the list of enabled analyzers
pub analyzers: AnalyzerConfig, pub analyzers: AnalyzerConfig,
/// Minimum disk space required to start a recording /// Minimum disk space required to start a recording
pub min_space_to_start_recording_mb: u64, pub min_space_to_start_recording_mb: u64,
/// Minimum disk space required to continue a recording /// Minimum disk space required to continue a recording
pub min_space_to_continue_recording_mb: u64, 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 /// Wifi client SSID
pub wifi_ssid: Option<String>, pub wifi_ssid: Option<String>,
/// Wifi client password /// Wifi client password
@@ -46,10 +84,43 @@ pub struct Config {
pub wifi_enabled: bool, pub wifi_enabled: bool,
/// Vector containing wifi client DNS servers /// Vector containing wifi client DNS servers
pub dns_servers: Option<Vec<String>>, pub dns_servers: Option<Vec<String>>,
/// Wifi client firewall mode /// WebDAV upload configuration. The upload worker runs whenever `webdav.url` is non-empty.
pub firewall_restrict_outbound: bool, pub webdav: WebdavConfig,
/// Vector containing additional wifi client firewall ports to open }
pub firewall_allowed_ports: Option<Vec<u16>>,
/// 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 { impl Default for Config {
@@ -59,21 +130,24 @@ impl Default for Config {
port: 8080, port: 8080,
debug_mode: false, debug_mode: false,
device: Device::Orbic, device: Device::Orbic,
ui_level: 1, ui_level: UiLevel::Subtle,
colorblind_mode: false, colorblind_mode: false,
key_input_mode: 0, key_input_mode: KeyInputMode::Disabled,
analyzers: AnalyzerConfig::default(), analyzers: AnalyzerConfig::default(),
ntfy_url: None, ntfy_url: None,
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery], enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
auto_check_updates: true,
min_space_to_start_recording_mb: 1, min_space_to_start_recording_mb: 1,
min_space_to_continue_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_ssid: None,
wifi_password: None, wifi_password: None,
wifi_security: None, wifi_security: None,
wifi_enabled: false, wifi_enabled: false,
dns_servers: None, dns_servers: None,
firewall_restrict_outbound: true, webdav: WebdavConfig::default(),
firewall_allowed_ports: None,
} }
} }
} }
+133 -34
View File
@@ -10,8 +10,11 @@ use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt, future}; use futures::{StreamExt, TryStreamExt, future};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rayhunter::Device;
use tokio::fs::File; 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::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot}; use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream; use tokio_stream::wrappers::LinesStream;
@@ -20,14 +23,15 @@ use tokio_util::task::TaskTracker;
#[cfg(feature = "apidocs")] #[cfg(feature = "apidocs")]
use rayhunter::analysis::analyzer::ReportMetadata; use rayhunter::analysis::analyzer::ReportMetadata;
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType}; use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::diag::{DataType, Message, MessagesContainer};
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter; use rayhunter::qmdl::QmdlWriter;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter}; use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::config::GpsMode;
use crate::display; use crate::display;
use crate::notifications::{Notification, NotificationType}; use crate::notifications::{Notification, NotificationType};
use crate::qmdl_store::{RecordingStore, RecordingStoreError}; use crate::qmdl_store::{FileKind, RecordingStore, RecordingStoreError};
use crate::server::ServerState; use crate::server::ServerState;
use crate::stats::DiskStats; use crate::stats::DiskStats;
@@ -36,7 +40,7 @@ const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
pub enum DiagDeviceCtrlMessage { pub enum DiagDeviceCtrlMessage {
StopRecording, StopRecording,
StartRecording { StartRecording {
response_tx: Option<oneshot::Sender<Result<(), String>>>, response_tx: Option<oneshot::Sender<Result<(), RecordingStoreError>>>,
}, },
DeleteEntry { DeleteEntry {
name: String, name: String,
@@ -45,6 +49,10 @@ pub enum DiagDeviceCtrlMessage {
DeleteAllEntries { DeleteAllEntries {
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>, response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
}, },
GpsUpdate {
lat: f64,
lon: f64,
},
Exit, Exit,
} }
@@ -55,10 +63,13 @@ pub struct DiagTask {
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64, min_space_to_start_mb: u64,
min_space_to_continue_mb: u64, min_space_to_continue_mb: u64,
gps_mode: GpsMode,
gps_fixed_coords: Option<(f64, f64)>,
state: DiagState, state: DiagState,
max_type_seen: EventType, max_type_seen: EventType,
bytes_since_space_check: usize, bytes_since_space_check: usize,
low_space_warned: bool, low_space_warned: bool,
latest_packet_timestamp: Option<i64>,
} }
enum DiagState { enum DiagState {
@@ -96,6 +107,7 @@ fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -
} }
impl DiagTask { impl DiagTask {
#[allow(clippy::too_many_arguments)]
fn new( fn new(
ui_update_sender: Sender<display::DisplayState>, ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
@@ -103,6 +115,8 @@ impl DiagTask {
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64, min_space_to_start_mb: u64,
min_space_to_continue_mb: u64, min_space_to_continue_mb: u64,
gps_mode: GpsMode,
gps_fixed_coords: Option<(f64, f64)>,
) -> Self { ) -> Self {
Self { Self {
ui_update_sender, ui_update_sender,
@@ -111,15 +125,18 @@ impl DiagTask {
notification_channel, notification_channel,
min_space_to_start_mb, min_space_to_start_mb,
min_space_to_continue_mb, min_space_to_continue_mb,
gps_mode,
gps_fixed_coords,
state: DiagState::Stopped, state: DiagState::Stopped,
max_type_seen: EventType::Informational, max_type_seen: EventType::Informational,
bytes_since_space_check: 0, bytes_since_space_check: 0,
low_space_warned: false, low_space_warned: false,
latest_packet_timestamp: None,
} }
} }
/// Start recording, returning an error if disk space is too low. /// Start recording, returning an error if disk space is too low.
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> { async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), RecordingStoreError> {
self.max_type_seen = EventType::Informational; self.max_type_seen = EventType::Informational;
self.bytes_since_space_check = 0; self.bytes_since_space_check = 0;
self.low_space_warned = false; self.low_space_warned = false;
@@ -130,12 +147,10 @@ impl DiagTask {
self.min_space_to_continue_mb, self.min_space_to_continue_mb,
) { ) {
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => { DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
let msg = format!( return Err(RecordingStoreError::InsufficientDiskSpace(
"Insufficient disk space: {}MB available, {}MB required", mb,
mb, self.min_space_to_start_mb self.min_space_to_start_mb,
); ));
error!("{msg}");
return Err(msg);
} }
DiskSpaceCheck::Ok(mb) => { DiskSpaceCheck::Ok(mb) => {
info!("Starting recording with {}MB disk space available", mb); info!("Starting recording with {}MB disk space available", mb);
@@ -143,29 +158,45 @@ impl DiagTask {
DiskSpaceCheck::Failed => {} DiskSpaceCheck::Failed => {}
} }
let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await { let (qmdl_file, analysis_file) = qmdl_store.new_entry(self.gps_mode).await?;
Ok(files) => files,
Err(e) => { // For fixed-mode sessions, write the configured coordinates to the storage
let msg = format!("failed creating QMDL file entry: {e}"); // immediately so the per-session GPS is stored durably and isn't affected
error!("{msg}"); // by future config changes or GPS API calls.
return Err(msg); 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; self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file); let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
{ .await
Ok(writer) => Box::new(writer), .map_err(RecordingStoreError::WriteFileError)?;
Err(e) => {
let msg = format!("failed to create analysis writer: {e}");
error!("{msg}");
return Err(msg);
}
};
self.state = DiagState::Recording { self.state = DiagState::Recording {
qmdl_writer, qmdl_writer,
analysis_writer, analysis_writer: Box::new(analysis_writer),
}; };
if let Err(e) = self if let Err(e) = self
.ui_update_sender .ui_update_sender
.send(display::DisplayState::Recording) .send(display::DisplayState::Recording)
@@ -233,6 +264,38 @@ impl DiagTask {
res 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) { async fn stop_current_recording(&mut self) {
let mut state = DiagState::Stopped; let mut state = DiagState::Stopped;
std::mem::swap(&mut self.state, &mut state); std::mem::swap(&mut self.state, &mut state);
@@ -328,6 +391,20 @@ impl DiagTask {
return; return;
} }
debug!("done!"); debug!("done!");
// 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(); let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
self.bytes_since_space_check += container_bytes; self.bytes_since_space_check += container_bytes;
let max_type = match analysis_writer.analyze(container).await { let max_type = match analysis_writer.analyze(container).await {
@@ -370,7 +447,7 @@ impl DiagTask {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread( pub fn run_diag_read_thread(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
mut dev: DiagDevice, device: Device,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>, mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>, qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>, ui_update_sender: Sender<display::DisplayState>,
@@ -380,10 +457,27 @@ pub fn run_diag_read_thread(
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64, min_space_to_start_mb: u64,
min_space_to_continue_mb: u64, min_space_to_continue_mb: u64,
gps_mode: GpsMode,
gps_fixed_coords: Option<(f64, f64)>,
) { ) {
task_tracker.spawn(async move { 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_stream = pin!(dev.as_stream().into_stream());
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); 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 qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None }) .send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await .await
@@ -424,6 +518,10 @@ pub fn run_diag_read_thread(
error!("Failed to send delete all entries respons, receiver dropped"); 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() => { maybe_container = diag_stream.next() => {
@@ -479,7 +577,7 @@ pub async fn start_recording(
match response_rx.await { match response_rx.await {
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())), Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)), Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason.to_string())),
Err(e) => Err(( Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive start recording response: {e}"), format!("failed to receive start recording response: {e}"),
@@ -649,9 +747,10 @@ pub async fn get_analysis_report(
))? ))?
}; };
let analysis_file = qmdl_store let analysis_file = qmdl_store
.open_entry_analysis(entry_index) .open_file(entry_index, FileKind::Analysis)
.await .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 // Read and normalize the NDJSON file
let reader = BufReader::new(analysis_file); let reader = BufReader::new(analysis_file);
+9 -10
View File
@@ -3,7 +3,7 @@ use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::F
use std::io::Cursor; use std::io::Cursor;
use std::time::Duration; use std::time::Duration;
use crate::config; use crate::config::{self, UiLevel};
use crate::display::DisplayState; use crate::display::DisplayState;
use rayhunter::analysis::analyzer::EventType; use rayhunter::analysis::analyzer::EventType;
@@ -176,7 +176,7 @@ pub fn update_ui(
) { ) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/"); static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
let display_level = config.ui_level; let display_level = config.ui_level;
if display_level == 0 { if display_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI."); info!("Invisible mode, not spawning UI.");
return; return;
} }
@@ -187,14 +187,14 @@ pub fn update_ui(
task_tracker.spawn(async move { task_tracker.spawn(async move {
// this feels wrong, is there a more rusty way to do this? // this feels wrong, is there a more rusty way to do this?
let mut img: Option<&[u8]> = None; let mut img: Option<&[u8]> = None;
if display_level == 2 { if display_level == UiLevel::Demo {
img = Some( img = Some(
IMAGE_DIR IMAGE_DIR
.get_file("orca.gif") .get_file("orca.gif")
.expect("failed to read orca.gif") .expect("failed to read orca.gif")
.contents(), .contents(),
); );
} else if display_level == 3 { } else if display_level == UiLevel::EffLogo {
img = Some( img = Some(
IMAGE_DIR IMAGE_DIR
.get_file("eff.png") .get_file("eff.png")
@@ -217,20 +217,19 @@ pub fn update_ui(
let mut status_bar_height = 2; let mut status_bar_height = 2;
match display_level { match display_level {
2 => fb.draw_gif(img.unwrap()).await, UiLevel::Demo => fb.draw_gif(img.unwrap()).await,
3 => fb.draw_img(img.unwrap()).await, UiLevel::EffLogo => fb.draw_img(img.unwrap()).await,
4 => { UiLevel::HighVisibility => {
status_bar_height = fb.dimensions().height; status_bar_height = fb.dimensions().height;
} }
128 => { UiLevel::TransFlag => {
fb.draw_line(Color::Cyan, 128).await; fb.draw_line(Color::Cyan, 128).await;
fb.draw_line(Color::Pink, 102).await; fb.draw_line(Color::Pink, 102).await;
fb.draw_line(Color::White, 76).await; fb.draw_line(Color::White, 76).await;
fb.draw_line(Color::Pink, 50).await; fb.draw_line(Color::Pink, 50).await;
fb.draw_line(Color::Cyan, 25).await; fb.draw_line(Color::Cyan, 25).await;
} }
// this branch is for ui_level 1, which is also the default if an // UiLevel::Subtle (1) and anything else: just the status bar line
// unknown value is used
_ => {} _ => {}
}; };
let (color, pattern) = display_style; let (color, pattern) = display_style;
+2 -2
View File
@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
use std::time::Duration; use std::time::Duration;
use crate::config; use crate::config::{self, UiLevel};
use crate::display::DisplayState; use crate::display::DisplayState;
macro_rules! led { macro_rules! led {
@@ -31,7 +31,7 @@ pub fn update_ui(
mut ui_update_rx: mpsc::Receiver<DisplayState>, mut ui_update_rx: mpsc::Receiver<DisplayState>,
) { ) {
let mut invisible: bool = false; let mut invisible: bool = false;
if config.ui_level == 0 { if config.ui_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI."); info!("Invisible mode, not spawning UI.");
invisible = true; invisible = true;
} }
+2 -2
View File
@@ -3,7 +3,7 @@ use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::config; use crate::config::{self, UiLevel};
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit}; use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
use std::fs; use std::fs;
@@ -15,7 +15,7 @@ pub fn update_ui(
ui_update_rx: Receiver<DisplayState>, ui_update_rx: Receiver<DisplayState>,
) { ) {
let display_level = config.ui_level; let display_level = config.ui_level;
if display_level == 0 { if display_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI."); info!("Invisible mode, not spawning UI.");
} }
+3 -3
View File
@@ -1,7 +1,7 @@
/// Display module for the TP-Link M7350 oled one-bit display. /// Display module for the TP-Link M7350 oled one-bit display.
/// ///
/// https://github.com/m0veax/tplink_m7350/tree/main/oled /// https://github.com/m0veax/tplink_m7350/tree/main/oled
use crate::config; use crate::config::{self, UiLevel};
use crate::display::DisplayState; use crate::display::DisplayState;
use log::{error, info}; use log::{error, info};
@@ -115,7 +115,7 @@ pub fn update_ui(
mut ui_update_rx: Receiver<DisplayState>, mut ui_update_rx: Receiver<DisplayState>,
) { ) {
let display_level = config.ui_level; let display_level = config.ui_level;
if display_level == 0 { if display_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI."); 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 // we write the status every second because it may have been overwritten through menu
// navigation. // navigation.
if display_level != 0 if display_level != UiLevel::Invisible
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await && let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
{ {
error!("failed to write to display: {e}"); error!("failed to write to display: {e}");
+2 -2
View File
@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
use std::time::Duration; use std::time::Duration;
use crate::config; use crate::config::{self, UiLevel};
use crate::display::DisplayState; use crate::display::DisplayState;
macro_rules! led { macro_rules! led {
@@ -31,7 +31,7 @@ pub fn update_ui(
mut ui_update_rx: mpsc::Receiver<DisplayState>, mut ui_update_rx: mpsc::Receiver<DisplayState>,
) { ) {
let mut invisible: bool = false; let mut invisible: bool = false;
if config.ui_level == 0 { if config.ui_level == UiLevel::Invisible {
info!("Invisible mode, not spawning UI."); info!("Invisible mode, not spawning UI.");
invisible = true; invisible = true;
} }
-3
View File
@@ -1,4 +1,3 @@
use rayhunter::diag_device::DiagDeviceError;
use thiserror::Error; use thiserror::Error;
use crate::qmdl_store::RecordingStoreError; use crate::qmdl_store::RecordingStoreError;
@@ -7,8 +6,6 @@ use crate::qmdl_store::RecordingStoreError;
pub enum RayhunterError { pub enum RayhunterError {
#[error("Config file parsing error: {0}")] #[error("Config file parsing error: {0}")]
ConfigFileParsingError(#[from] toml::de::Error), ConfigFileParsingError(#[from] toml::de::Error),
#[error("Diag intialization error: {0}")]
DiagInitError(DiagDeviceError),
#[error("Tokio error: {0}")] #[error("Tokio error: {0}")]
TokioError(#[from] tokio::io::Error), TokioError(#[from] tokio::io::Error),
#[error("QmdlStore error: {0}")] #[error("QmdlStore error: {0}")]
-92
View File
@@ -1,92 +0,0 @@
use anyhow::{Result, bail};
use log::{info, warn};
use tokio::process::Command;
use wifi_station::detect_bridge_iface;
use crate::config::Config;
async fn run_iptables(args: &[&str]) -> Result<()> {
let out = Command::new("iptables").args(args).output().await?;
if !out.status.success() {
bail!(
"iptables {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
pub async fn apply(config: &Config) {
let _ = Command::new("iptables")
.args(["-F", "OUTPUT"])
.output()
.await;
if config.firewall_restrict_outbound {
// Fail open on partial setup error: reachability beats restriction when recovery means physical access.
match setup_outbound_whitelist(&config.firewall_allowed_ports, &config.ntfy_url).await {
Ok(()) => info!("outbound firewall active: allowing DHCP, DNS, HTTPS only"),
Err(e) => warn!("firewall setup failed: {e} (fail-open, outbound unrestricted)"),
}
}
}
async fn setup_outbound_whitelist(
extra_ports: &Option<Vec<u16>>,
ntfy_url: &Option<String>,
) -> Result<()> {
run_iptables(&["-A", "OUTPUT", "-o", "lo", "-j", "ACCEPT"]).await?;
run_iptables(&["-A", "OUTPUT", "-o", detect_bridge_iface(), "-j", "ACCEPT"]).await?;
run_iptables(&[
"-A",
"OUTPUT",
"-m",
"state",
"--state",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
])
.await?;
run_iptables(&[
"-A", "OUTPUT", "-p", "udp", "--dport", "67:68", "-j", "ACCEPT",
])
.await?;
run_iptables(&["-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "ACCEPT"]).await?;
run_iptables(&["-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-j", "ACCEPT"]).await?;
run_iptables(&[
"-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "ACCEPT",
])
.await?;
if let Some(url) = ntfy_url
&& let Ok(parsed) = url::Url::parse(url)
&& let Some(port) = parsed.port_or_known_default()
&& port != 443
{
let port_str = port.to_string();
run_iptables(&[
"-A", "OUTPUT", "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
])
.await?;
info!("firewall: auto-allowed port {port} for ntfy");
}
if let Some(ports) = extra_ports {
for port in ports {
let port_str = port.to_string();
run_iptables(&[
"-A", "OUTPUT", "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
])
.await?;
}
}
run_iptables(&["-A", "OUTPUT", "-j", "DROP"]).await?;
let _ = tokio::fs::write("/proc/sys/net/bridge/bridge-nf-call-iptables", "0").await;
Ok(())
}
+151
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),
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::config; use crate::config::{self, KeyInputMode};
use crate::diag::DiagDeviceCtrlMessage; use crate::diag::DiagDeviceCtrlMessage;
#[derive(Debug)] #[derive(Debug)]
@@ -23,7 +23,7 @@ pub fn run_key_input_thread(
diag_tx: Sender<DiagDeviceCtrlMessage>, diag_tx: Sender<DiagDeviceCtrlMessage>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
) { ) {
if config.key_input_mode == 0 { if config.key_input_mode == KeyInputMode::Disabled {
return; return;
} }
+7 -2
View File
@@ -5,13 +5,15 @@ pub mod crypto_provider;
pub mod diag; pub mod diag;
pub mod display; pub mod display;
pub mod error; pub mod error;
pub mod firewall; pub mod gps;
pub mod key_input; pub mod key_input;
pub mod notifications; pub mod notifications;
pub mod pcap; pub mod pcap;
pub mod qmdl_store; pub mod qmdl_store;
pub mod server; pub mod server;
pub mod stats; pub mod stats;
pub mod update;
pub mod webdav;
#[cfg(feature = "apidocs")] #[cfg(feature = "apidocs")]
use utoipa::OpenApi; use utoipa::OpenApi;
@@ -33,6 +35,7 @@ use utoipa::OpenApi;
server::get_zip, server::get_zip,
stats::get_system_stats, stats::get_system_stats,
stats::get_qmdl_manifest, stats::get_qmdl_manifest,
stats::get_update_status,
stats::get_log, stats::get_log,
diag::start_recording, diag::start_recording,
diag::stop_recording, diag::stop_recording,
@@ -46,7 +49,9 @@ use utoipa::OpenApi;
server::test_notification, server::test_notification,
server::get_time, server::get_time,
server::set_time_offset, server::set_time_offset,
server::debug_set_display_state server::debug_set_display_state,
gps::post_gps,
gps::get_gps
), ),
servers( servers(
( (
+74 -27
View File
@@ -5,20 +5,24 @@ mod crypto_provider;
mod diag; mod diag;
mod display; mod display;
mod error; mod error;
mod firewall; mod gps;
mod key_input; mod key_input;
mod notifications; mod notifications;
mod pcap; mod pcap;
mod qmdl_store; mod qmdl_store;
mod server; mod server;
mod stats; mod stats;
mod update;
mod webdav;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use crate::battery::run_battery_notification_worker; 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::diag::run_diag_read_thread;
use crate::error::RayhunterError; use crate::error::RayhunterError;
use crate::gps::{get_gps, post_gps};
use crate::notifications::{NotificationService, run_notification_worker}; use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap; use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
@@ -26,7 +30,9 @@ use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip, 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, 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 wifi_station::WifiStatus;
use analysis::{ use analysis::{
@@ -39,10 +45,9 @@ use diag::{
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report, DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
start_recording, stop_recording, start_recording, stop_recording,
}; };
use log::{error, info}; use log::{error, info, warn};
use qmdl_store::RecordingStoreError; use qmdl_store::RecordingStoreError;
use rayhunter::Device; use rayhunter::Device;
use rayhunter::diag_device::DiagDevice;
use stats::get_log; use stats::get_log;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::select; use tokio::select;
@@ -60,6 +65,7 @@ fn get_router() -> AppRouter {
.route("/api/qmdl/{name}", get(get_qmdl)) .route("/api/qmdl/{name}", get(get_qmdl))
.route("/api/zip/{name}", get(get_zip)) .route("/api/zip/{name}", get(get_zip))
.route("/api/system-stats", get(get_system_stats)) .route("/api/system-stats", get(get_system_stats))
.route("/api/update-status", get(get_update_status))
.route("/api/qmdl-manifest", get(get_qmdl_manifest)) .route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/log", get(get_log)) .route("/api/log", get(get_log))
.route("/api/start-recording", post(start_recording)) .route("/api/start-recording", post(start_recording))
@@ -77,6 +83,8 @@ fn get_router() -> AppRouter {
.route("/api/time", get(get_time)) .route("/api/time", get(get_time))
.route("/api/time-offset", post(set_time_offset)) .route("/api/time-offset", post(set_time_offset))
.route("/api/debug/display-state", post(debug_set_display_state)) .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("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static)) .route("/{*path}", get(serve_static))
} }
@@ -212,20 +220,17 @@ async fn run_with_config(
let _shutdown_guard = shutdown_token.clone().drop_guard(); let _shutdown_guard = shutdown_token.clone().drop_guard();
let notification_service = NotificationService::new(config.ntfy_url.clone()); let notification_service = NotificationService::new(config.ntfy_url.clone());
let update_status_lock = Arc::new(RwLock::new(UpdateStatus::default()));
if !config.debug_mode { 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"); 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( run_diag_read_thread(
&task_tracker, &task_tracker,
dev, config.device.clone(),
diag_rx, diag_rx,
diag_tx.clone(), diag_tx.clone(),
ui_update_tx.clone(), ui_update_tx.clone(),
@@ -235,6 +240,8 @@ async fn run_with_config(
notification_service.new_handler(), notification_service.new_handler(),
config.min_space_to_start_recording_mb, config.min_space_to_start_recording_mb,
config.min_space_to_continue_recording_mb, config.min_space_to_continue_recording_mb,
config.gps_mode,
gps_fixed_coords,
); );
info!("Starting UI"); info!("Starting UI");
@@ -255,6 +262,16 @@ async fn run_with_config(
diag_tx.clone(), diag_tx.clone(),
shutdown_token.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)); let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
@@ -274,12 +291,14 @@ async fn run_with_config(
analysis_tx.clone(), analysis_tx.clone(),
); );
run_battery_notification_worker( if !config.debug_mode {
&task_tracker, run_battery_notification_worker(
config.device.clone(), &task_tracker,
notification_service.new_handler(), config.device.clone(),
shutdown_token.clone(), notification_service.new_handler(),
); shutdown_token.clone(),
);
}
run_notification_worker( run_notification_worker(
&task_tracker, &task_tracker,
@@ -288,13 +307,39 @@ async fn run_with_config(
); );
let wifi_status = Arc::new(RwLock::new(WifiStatus::default())); let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
wifi_station::run_wifi_client( if !config.debug_mode {
&task_tracker, wifi_station::run_wifi_client(
&config.wifi_config(), &task_tracker,
shutdown_token.clone(), &config.wifi_config(),
wifi_status.clone(), shutdown_token.clone(),
); wifi_status.clone(),
firewall::apply(&config).await; );
}
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 { let state = Arc::new(ServerState {
config_path: args.config_path.clone(), config_path: args.config_path.clone(),
@@ -307,6 +352,8 @@ async fn run_with_config(
ui_update_sender: Some(ui_update_tx), ui_update_sender: Some(ui_update_tx),
wifi_status, wifi_status,
wifi_scan_lock: tokio::sync::Mutex::new(()), 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; run_server(&task_tracker, state, shutdown_token.clone()).await;
+2 -1
View File
@@ -26,6 +26,7 @@ pub enum NotificationError {
pub enum NotificationType { pub enum NotificationType {
Warning, Warning,
LowBattery, LowBattery,
Update,
} }
pub struct Notification { pub struct Notification {
@@ -290,7 +291,7 @@ mod tests {
println!("error = {:?}", reqwest_error); println!("error = {:?}", reqwest_error);
assert!(reqwest_error.is_timeout()); assert!(reqwest_error.is_timeout());
} }
_ => assert!(false), _ => unreachable!(),
} }
} }
+137 -11
View File
@@ -1,5 +1,8 @@
use crate::gps::{GpsRecord, load_gps_records};
use crate::qmdl_store::FileKind;
use crate::server::ServerState; use crate::server::ServerState;
use crate::config::GpsMode;
use anyhow::Error; use anyhow::Error;
use axum::body::Body; use axum::body::Body;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
@@ -9,15 +12,12 @@ use axum::response::{IntoResponse, Response};
use log::error; use log::error;
use rayhunter::diag::DataType; use rayhunter::diag::DataType;
use rayhunter::gsmtap_parser; use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter; use rayhunter::pcap::{GpsPoint, GsmtapPcapWriter};
use rayhunter::qmdl::QmdlReader; use rayhunter::qmdl::QmdlReader;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite, duplex}; use tokio::io::{AsyncRead, AsyncWrite, duplex};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
// 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( #[cfg_attr(feature = "apidocs", utoipa::path(
get, get,
path = "/api/pcap/{name}", path = "/api/pcap/{name}",
@@ -53,15 +53,16 @@ pub async fn get_pcap(
} }
let qmdl_size_bytes = entry.qmdl_size_bytes; let qmdl_size_bytes = entry.qmdl_size_bytes;
let qmdl_file = qmdl_store let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index) .open_file(entry_index, FileKind::Qmdl)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
// the QMDL reader should stop at the last successfully written data chunk .ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
// (entry.size_bytes)
let (reader, writer) = duplex(1024); let (reader, writer) = duplex(1024);
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
drop(qmdl_store);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await { if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes, gps_records).await {
error!("failed to generate PCAP: {e:?}"); error!("failed to generate PCAP: {e:?}");
} }
}); });
@@ -71,10 +72,68 @@ pub async fn get_pcap(
Ok((headers, body).into_response()) 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>( pub async fn generate_pcap_data<R, W>(
writer: W, writer: W,
qmdl_file: R, qmdl_file: R,
qmdl_size_bytes: usize, qmdl_size_bytes: usize,
gps_records: Vec<GpsRecord>,
) -> Result<(), Error> ) -> Result<(), Error>
where where
W: AsyncWrite + Unpin + Send, W: AsyncWrite + Unpin + Send,
@@ -89,13 +148,15 @@ where
continue; continue;
} }
for maybe_msg in container.into_messages() { for maybe_msg in container.messages() {
match maybe_msg { match maybe_msg {
Ok(msg) => { Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?; let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_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 pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp) .write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref())
.await?; .await?;
} }
} }
@@ -106,3 +167,68 @@ where
Ok(()) 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);
}
}
+224 -57
View File
@@ -1,8 +1,10 @@
use std::fmt::Display;
use std::io::{self, ErrorKind}; use std::io::{self, ErrorKind};
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{DateTime, Local}; use crate::config::GpsMode;
use chrono::{DateTime, Local, TimeDelta};
use log::{info, warn}; use log::{info, warn};
use rayhunter::util::RuntimeMetadata; use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -22,6 +24,8 @@ pub enum RecordingStoreError {
CreateFileError(tokio::io::Error), CreateFileError(tokio::io::Error),
#[error("Couldn't read file: {0}")] #[error("Couldn't read file: {0}")]
ReadFileError(tokio::io::Error), ReadFileError(tokio::io::Error),
#[error("Couldn't write file: {0}")]
WriteFileError(tokio::io::Error),
#[error("Couldn't delete file: {0}")] #[error("Couldn't delete file: {0}")]
DeleteFileError(tokio::io::Error), DeleteFileError(tokio::io::Error),
#[error("Couldn't open directory at path: {0}")] #[error("Couldn't open directory at path: {0}")]
@@ -32,6 +36,46 @@ pub enum RecordingStoreError {
WriteManifestError(tokio::io::Error), WriteManifestError(tokio::io::Error),
#[error("Couldn't parse QMDL store manifest file: {0}")] #[error("Couldn't parse QMDL store manifest file: {0}")]
ParseManifestError(toml::de::Error), 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 { pub struct RecordingStore {
@@ -67,10 +111,15 @@ pub struct ManifestEntry {
pub arch: Option<String>, pub arch: Option<String>,
#[serde(default)] #[serde(default)]
pub stop_reason: Option<String>, 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 { impl ManifestEntry {
fn new() -> Self { fn new(gps_mode: GpsMode) -> Self {
let now = rayhunter::clock::get_adjusted_now(); let now = rayhunter::clock::get_adjusted_now();
let metadata = RuntimeMetadata::new(); let metadata = RuntimeMetadata::new();
ManifestEntry { ManifestEntry {
@@ -82,19 +131,13 @@ impl ManifestEntry {
system_os: Some(metadata.system_os), system_os: Some(metadata.system_os),
arch: Some(metadata.arch), arch: Some(metadata.arch),
stop_reason: None, stop_reason: None,
upload_time: None,
gps_mode: Some(gps_mode),
} }
} }
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf { pub fn get_filepath<P: AsRef<Path>>(&self, file_kind: FileKind, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name); file_kind.get_filepath(&self.name, path)
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
} }
} }
@@ -212,6 +255,8 @@ impl RecordingStore {
system_os: None, system_os: None,
arch: None, arch: None,
stop_reason: None, stop_reason: None,
upload_time: None,
gps_mode: None,
}); });
} }
@@ -244,43 +289,63 @@ impl RecordingStore {
// Closes the current entry (if needed), creates a new entry based on the // 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 // current time, and updates the manifest. Returns a tuple of the entry's
// newly created QMDL file and analysis file. // 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 we've already got an entry open, close it
if self.current_entry.is_some() { if self.current_entry.is_some() {
self.close_current_entry().await?; self.close_current_entry().await?;
} }
let new_entry = ManifestEntry::new(); let new_entry = ManifestEntry::new(gps_mode);
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path); let qmdl_filepath = new_entry.get_filepath(FileKind::Qmdl, &self.path);
let qmdl_file = File::create(&qmdl_filepath) let qmdl_file = File::create(&qmdl_filepath)
.await .await
.map_err(RecordingStoreError::CreateFileError)?; .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) let analysis_file = File::create(&analysis_filepath)
.await .await
.map_err(RecordingStoreError::CreateFileError)?; .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.manifest.entries.push(new_entry);
self.current_entry = Some(self.manifest.entries.len() - 1); self.current_entry = Some(self.manifest.entries.len() - 1);
self.write_manifest().await?; self.write_manifest().await?;
Ok((qmdl_file, analysis_file)) Ok((qmdl_file, analysis_file))
} }
// Returns the corresponding QMDL file for a given entry pub async fn open_file(
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(
&self, &self,
entry_index: usize, entry_index: usize,
) -> Result<File, RecordingStoreError> { file_kind: FileKind,
) -> Result<Option<File>, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index]; 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 .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( pub async fn clear_and_open_entry_analysis(
@@ -291,7 +356,7 @@ impl RecordingStore {
let file = OpenOptions::new() let file = OpenOptions::new()
.write(true) .write(true)
.truncate(true) .truncate(true)
.open(entry.get_analysis_filepath(&self.path)) .open(entry.get_filepath(FileKind::Analysis, &self.path))
.await .await
.map_err(RecordingStoreError::ReadFileError)?; .map_err(RecordingStoreError::ReadFileError)?;
Ok(file) Ok(file)
@@ -342,6 +407,23 @@ impl RecordingStore {
Ok(()) 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 // Finds an entry by filename
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> { pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
let entry_index = self let entry_index = self
@@ -368,6 +450,22 @@ impl RecordingStore {
Ok(()) 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 { pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry { match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) { Some(idx) => match self.manifest.entries.get(idx) {
@@ -396,14 +494,13 @@ impl RecordingStore {
}; };
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx); let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?; self.write_manifest().await?;
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path); for &file_kind in FileKind::ALL {
remove_file_if_exists(&qmdl_filepath) let filepath = file_kind.get_filepath(&entry_to_delete.name, &self.path);
.await remove_file_if_exists(&filepath)
.map_err(RecordingStoreError::DeleteFileError)?; .await
remove_file_if_exists(&analysis_filepath) .map_err(RecordingStoreError::DeleteFileError)?;
.await }
.map_err(RecordingStoreError::DeleteFileError)?;
Ok(()) Ok(())
} }
@@ -414,20 +511,16 @@ impl RecordingStore {
let mut keep = Vec::new(); let mut keep = Vec::new();
for entry in &self.manifest.entries { 'entries: for entry in &self.manifest.entries {
let qmdl_filepath = entry.get_qmdl_filepath(&self.path); for &file_kind in FileKind::ALL {
let analysis_filepath = entry.get_analysis_filepath(&self.path); let filepath = file_kind.get_filepath(&entry.name, &self.path);
if let Err(e) = remove_file_if_exists(&filepath).await {
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await { log::warn!("failed to remove {filepath:?}: {e:?}");
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}"); // Some error happened with deleting this entry, abort and go to the next one.
keep.push(true); // Also *keep* the manifest entry.
continue; keep.push(true);
} continue 'entries;
}
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
keep.push(true);
continue;
} }
keep.push(false); keep.push(false);
@@ -470,7 +563,7 @@ mod tests {
async fn test_creating_updating_and_closing_entries() { async fn test_creating_updating_and_closing_entries() {
let dir = make_temp_dir(); let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap(); 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 entry_index = store.current_entry.unwrap();
assert_eq!( assert_eq!(
RecordingStore::read_manifest(dir.path()).await.unwrap(), RecordingStore::read_manifest(dir.path()).await.unwrap(),
@@ -507,7 +600,7 @@ mod tests {
async fn test_create_on_existing_store() { async fn test_create_on_existing_store() {
let dir = make_temp_dir(); let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap(); 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 entry_index = store.current_entry.unwrap();
store store
.update_entry_qmdl_size(entry_index, 1000) .update_entry_qmdl_size(entry_index, 1000)
@@ -521,9 +614,9 @@ mod tests {
async fn test_repeated_new_entries() { async fn test_repeated_new_entries() {
let dir = make_temp_dir(); let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap(); 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 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(); let new_entry_index = store.current_entry.unwrap();
assert_ne!(entry_index, new_entry_index); assert_ne!(entry_index, new_entry_index);
assert_eq!(store.manifest.entries.len(), 2); assert_eq!(store.manifest.entries.len(), 2);
@@ -533,7 +626,7 @@ mod tests {
async fn test_delete_all_entries() { async fn test_delete_all_entries() {
let dir = make_temp_dir(); let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap(); 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()); assert!(store.current_entry.is_some());
store.delete_all_entries().await.unwrap(); store.delete_all_entries().await.unwrap();
@@ -544,4 +637,78 @@ mod tests {
store.delete_all_entries().await.unwrap(); store.delete_all_entries().await.unwrap();
assert!(store.current_entry.is_none()); 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);
}
} }
+62 -23
View File
@@ -22,12 +22,14 @@ use tokio_util::io::ReaderStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config; use crate::config::{Config, GpsMode};
use crate::diag::DiagDeviceCtrlMessage; use crate::diag::DiagDeviceCtrlMessage;
use crate::display::DisplayState; use crate::display::DisplayState;
use crate::gps::GpsData;
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT; use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
use crate::pcap::generate_pcap_data; use crate::pcap::{generate_pcap_data, load_gps_records_for_entry};
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::{FileKind, RecordingStore};
use crate::update::UpdateStatus;
pub struct ServerState { pub struct ServerState {
pub config_path: String, pub config_path: String,
@@ -40,6 +42,8 @@ pub struct ServerState {
pub ui_update_sender: Option<Sender<DisplayState>>, pub ui_update_sender: Option<Sender<DisplayState>>,
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>, pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
pub wifi_scan_lock: tokio::sync::Mutex<()>, 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( #[cfg_attr(feature = "apidocs", utoipa::path(
@@ -68,14 +72,15 @@ pub async fn get_qmdl(
format!("couldn't find qmdl file with name {qmdl_idx}"), format!("couldn't find qmdl file with name {qmdl_idx}"),
))?; ))?;
let qmdl_file = qmdl_store let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index) .open_file(entry_index, FileKind::Qmdl)
.await .await
.map_err(|err| { .map_err(|err| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {err}"), format!("error opening QMDL file: {err}"),
) )
})?; })?
.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 limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
let qmdl_stream = ReaderStream::new(limited_qmdl_file); let qmdl_stream = ReaderStream::new(limited_qmdl_file);
@@ -160,8 +165,12 @@ pub async fn get_config(
))] ))]
pub async fn set_config( pub async fn set_config(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Json(config): Json<Config>, Json(mut config): Json<Config>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
if config.gps_mode != GpsMode::Fixed {
config.gps_fixed_latitude = None;
config.gps_fixed_longitude = None;
}
let mut config_to_write = config.clone(); let mut config_to_write = config.clone();
config_to_write.wifi_ssid = None; config_to_write.wifi_ssid = None;
config_to_write.wifi_password = None; config_to_write.wifi_password = None;
@@ -343,6 +352,7 @@ pub async fn get_zip(
}; };
let qmdl_store_lock = state.qmdl_store_lock.clone(); 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); let (reader, writer) = duplex(8192);
@@ -350,24 +360,40 @@ pub async fn get_zip(
let result: Result<(), Error> = async { let result: Result<(), Error> = async {
let mut zip = ZipFileWriter::with_tokio(writer); let mut zip = ZipFileWriter::with_tokio(writer);
// Add QMDL file const EXCLUDED_FROM_ZIP: &[FileKind] = &[FileKind::Analysis];
{
let entry = // Add stored files
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored); 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 // FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed // 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. // 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 entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let mut qmdl_file = { // Truncating to qmdl_size_bytes is an attempt to ignore partial writes by the diag
let qmdl_store = qmdl_store_lock.read().await; // thread.
qmdl_store if file_kind == FileKind::Qmdl {
.open_entry_qmdl(entry_index) copy(&mut file.take(qmdl_size_bytes as u64), &mut entry_writer).await?;
.await? } else {
.take(qmdl_size_bytes as u64) copy(&mut file, &mut entry_writer).await?;
}; }
copy(&mut qmdl_file, &mut entry_writer).await?;
entry_writer.into_inner().close().await?; entry_writer.into_inner().close().await?;
} }
@@ -380,13 +406,19 @@ pub async fn get_zip(
let qmdl_file_for_pcap = { let qmdl_file_for_pcap = {
let qmdl_store = qmdl_store_lock.read().await; let qmdl_store = qmdl_store_lock.read().await;
qmdl_store qmdl_store
.open_entry_qmdl(entry_index) .open_file(entry_index, FileKind::Qmdl)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("QMDL file not found"))?
.take(qmdl_size_bytes as u64) .take(qmdl_size_bytes as u64)
}; };
if let Err(e) = if let Err(e) = generate_pcap_data(
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await &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 // if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL. // user the QMDL.
@@ -501,6 +533,7 @@ pub async fn debug_set_display_state(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::GpsMode;
use async_zip::base::read::mem::ZipFileReader; use async_zip::base::read::mem::ZipFileReader;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use tempfile::TempDir; use tempfile::TempDir;
@@ -520,7 +553,7 @@ mod tests {
) -> String { ) -> String {
let entry_name = { let entry_name = {
let mut store = store_lock.write().await; 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() { if !test_data.is_empty() {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -566,6 +599,8 @@ mod tests {
ui_update_sender: None, ui_update_sender: None,
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())), wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
wifi_scan_lock: tokio::sync::Mutex::new(()), wifi_scan_lock: tokio::sync::Mutex::new(()),
gps_state: Arc::new(RwLock::new(None)),
update_status_lock: Arc::new(RwLock::new(UpdateStatus::default())),
}) })
} }
@@ -598,7 +633,11 @@ mod tests {
assert_eq!( assert_eq!(
filenames, filenames,
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),] vec![
format!("{entry_name}.qmdl"),
format!("{entry_name}-gps.ndjson"),
format!("{entry_name}.pcapng"),
]
); );
} }
} }
+15
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use crate::battery::get_battery_status; use crate::battery::get_battery_status;
use crate::error::RayhunterError; use crate::error::RayhunterError;
use crate::server::ServerState; use crate::server::ServerState;
use crate::update::UpdateStatus;
use crate::{battery::BatteryState, qmdl_store::ManifestEntry}; use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
use axum::Json; use axum::Json;
@@ -220,6 +221,20 @@ 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( #[cfg_attr(feature = "apidocs", utoipa::path(
get, get,
path = "/api/log", path = "/api/log",
+274
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
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
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(),
);
}
+6 -1
View File
@@ -22,7 +22,7 @@ export default ts.config(
}, },
}, },
{ {
files: ['**/*.svelte'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
@@ -48,6 +48,11 @@ export default ts.config(
format: ['snake_case'], 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',
}, },
} }
); );
+1317 -2893
View File
File diff suppressed because it is too large Load Diff
+19 -18
View File
@@ -15,25 +15,26 @@
"fix": "eslint --fix ." "fix": "eslint --fix ."
}, },
"devDependencies": { "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/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.53.4", "@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"@types/node": "^24.7.0", "@types/node": "^25.6.0",
"autoprefixer": "^10.4.20", "eslint": "^10.2.1",
"eslint": "^9.7.0", "eslint-config-prettier": "^10.1.8",
"eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^3.17.1",
"eslint-plugin-svelte": "^2.36.0", "globals": "^17.5.0",
"globals": "^15.0.0", "prettier": "^3.8.3",
"prettier": "^3.3.2", "prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-svelte": "^3.2.6", "svelte": "^5.55.7",
"svelte": "^5.53.7", "svelte-check": "^4.4.6",
"svelte-check": "^4.0.0", "tailwindcss": "^4.2.2",
"tailwindcss": "^3.4.9", "typescript": "^6.0.3",
"typescript": "^5.0.0", "typescript-eslint": "^8.59.0",
"typescript-eslint": "^8.0.0", "vite": "^8.0.10",
"vite": "^7.3.2", "vitest": "^4.1.5"
"vitest": "^3.2.4"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+16 -3
View File
@@ -1,3 +1,16 @@
@import 'tailwindcss/base'; @import 'tailwindcss';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @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;
}
}
@@ -20,7 +20,7 @@
{#if action_errors.length > 0} {#if action_errors.length > 0}
<div <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" border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
> >
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { type ReportMetadata } from '$lib/analysis.svelte'; import { type ReportMetadata } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte'; import type { ManifestEntry } from '$lib/manifest.svelte';
import { gps_mode_label } from '$lib/utils.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte'; import { AnalysisManager } from '$lib/analysisManager.svelte';
import AnalysisTable from './AnalysisTable.svelte'; import AnalysisTable from './AnalysisTable.svelte';
import ReAnalyzeButton from './ReAnalyzeButton.svelte'; import ReAnalyzeButton from './ReAnalyzeButton.svelte';
@@ -13,6 +14,11 @@
manager: AnalysisManager; manager: AnalysisManager;
current: boolean; current: boolean;
} = $props(); } = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: 'long',
dateStyle: 'short',
});
</script> </script>
<div class="container mt-2"> <div class="container mt-2">
@@ -49,20 +55,34 @@
{:else} {:else}
<p>No warnings to display!</p> <p>No warnings to display!</p>
{/if} {/if}
{#if metadata !== undefined && metadata.rayhunter !== undefined} <div>
<div> <p class="text-lg underline">Metadata</p>
<p class="text-lg underline">Metadata</p> {#if metadata !== undefined && metadata.rayhunter !== undefined}
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p> <p><b>Rayhunter version:</b> {metadata.rayhunter.rayhunter_version}</p>
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</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> <div>
<p class="text-lg underline">Analyzers</p> <p class="text-lg underline">Enabled Analyzers</p>
{#each metadata.analyzers as analyzer} {#each metadata.analyzers as analyzer}
<p><b>{analyzer.name}:</b> {analyzer.description}</p> <p><b>{analyzer.name}:</b> {analyzer.description}</p>
{/each} {/each}
</div> </div>
{:else}
<p>N/A (analysis generated by an older version of rayhunter)</p>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -55,7 +55,7 @@
{#if show_alert} {#if show_alert}
<div <div
class="bg-yellow-100 border-yellow-400 drop-shadow p-4 flex flex-col gap-2 border rounded-md" 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"> <span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
<svg <svg
+336 -169
View File
@@ -5,11 +5,14 @@
test_notification, test_notification,
get_wifi_status, get_wifi_status,
scan_wifi_networks, scan_wifi_networks,
GpsMode,
enabled_notifications,
type Config, type Config,
type WifiStatus, type WifiStatus,
type WifiNetwork, type WifiNetwork,
} from '../utils.svelte'; } from '../utils.svelte';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import ExpandableInput from './ExpandableInput.svelte';
let { shown = $bindable() }: { shown: boolean } = $props(); let { shown = $bindable() }: { shown: boolean } = $props();
let config = $state<Config | null>(null); let config = $state<Config | null>(null);
@@ -164,13 +167,13 @@
<select <select
id="ui_level" id="ui_level"
bind:value={config.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={0}>Invisible mode</option>
<option value={1}>1 - Subtle mode (colored line)</option> <option value={1}>Subtle mode (colored line)</option>
<option value={2}>2 - Demo mode (orca gif)</option> <option value={2}>Demo mode (orca gif)</option>
<option value={3}>3 - EFF logo</option> <option value={3}>EFF logo</option>
<option value={4}>4 - High visibility (full screen color)</option> <option value={4}>High visibility (full screen color)</option>
</select> </select>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Note: Rayhunter draws over the device's native UI, so some flickering is Note: Rayhunter draws over the device's native UI, so some flickering is
@@ -188,11 +191,10 @@
<select <select
id="key_input_mode" id="key_input_mode"
bind:value={config.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={0}>Disable button control</option>
<option value={1}>1 - Double-tap power button to start new recording</option <option value={1}>Double-tap power button to start new recording</option>
>
</select> </select>
</div> </div>
@@ -202,7 +204,7 @@
id="colorblind_mode" id="colorblind_mode"
type="checkbox" type="checkbox"
bind:checked={config.colorblind_mode} 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"> <label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
Colorblind Mode Colorblind Mode
@@ -210,100 +212,125 @@
</div> </div>
</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> <h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
<div>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1"> <div class="flex items-center">
ntfy URL for Sending Notifications (if unset you will not receive
notifications)
</label>
<input <input
id="ntfy_url" id="auto_check_updates"
type="url" type="checkbox"
bind:value={config.ntfy_url} bind:checked={config.auto_check_updates}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<p class="text-xs text-gray-500 mt-1"> <label for="auto_check_updates" class="ml-2 block text-sm text-gray-700">
Test button below uses the saved configuration URL, not the input above Automatically check for software updates
</p> </label>
</div> </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>
<div> <ExpandableInput
<button bind:value={config.ntfy_url}
type="button" checkboxId="ntfy_enabled"
onclick={send_test_notification} inputId="ntfy_url"
disabled={testingNotification} label="Enable ntfy notifications"
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" inputLabel="ntfy URL"
> inputPlaceholder="https://ntfy.sh/my-rayhunter"
{#if testingNotification} 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 <div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" class="mt-2 p-2 rounded-sm text-sm {testMessageType === 'error'
></div> ? 'bg-red-100 text-red-700'
Sending... : 'bg-green-100 text-green-700'}"
{:else}
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path {testMessage}
stroke-linecap="round" </div>
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
></path>
</svg>
Send Test Notification
{/if} {/if}
</button> </div>
{#if testMessage}
<div
class="mt-2 p-2 rounded 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="space-y-2">
<div class="block text-sm font-medium text-gray-700 mb-1"> <div class="block text-sm font-medium text-gray-700 mb-1">
Enabled Notification Types 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> </div>
<div class="flex items-center"> </ExpandableInput>
<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>
</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">Storage Management</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
<div> <div>
@@ -318,7 +345,7 @@
type="number" type="number"
min="1" min="1"
bind:value={config.min_space_to_start_recording_mb} bind:value={config.min_space_to_start_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" 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"> <p class="text-xs text-gray-500 mt-1">
Recording will not start if less than this amount of disk space is free Recording will not start if less than this amount of disk space is free
@@ -337,7 +364,7 @@
type="number" type="number"
min="1" min="1"
bind:value={config.min_space_to_continue_recording_mb} bind:value={config.min_space_to_continue_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" 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"> <p class="text-xs text-gray-500 mt-1">
Recording will stop automatically if disk space drops below this level Recording will stop automatically if disk space drops below this level
@@ -345,8 +372,137 @@
</div> </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">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'} {#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
<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">WiFi Client Mode</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Connect the device to an existing WiFi network for internet access (e.g. Connect the device to an existing WiFi network for internet access (e.g.
@@ -359,7 +515,7 @@
id="wifi_enabled" id="wifi_enabled"
type="checkbox" type="checkbox"
bind:checked={config.wifi_enabled} bind:checked={config.wifi_enabled}
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="wifi_enabled" class="ml-2 block text-sm text-gray-700"> <label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
Enable WiFi Enable WiFi
@@ -402,7 +558,7 @@
type="text" type="text"
bind:value={config.wifi_ssid} bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork" placeholder="MyWiFiNetwork"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/> />
<button <button
type="button" type="button"
@@ -421,7 +577,7 @@
{#if scanResults.length > 0} {#if scanResults.length > 0}
<div <div
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y" class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y divide-gray-200"
> >
{#each scanResults as network} {#each scanResults as network}
<button <button
@@ -449,7 +605,7 @@
<select <select
id="wifi_security" id="wifi_security"
bind:value={config.wifi_security} bind:value={config.wifi_security}
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="wpa_psk">WPA2 (WPA-PSK)</option> <option value="wpa_psk">WPA2 (WPA-PSK)</option>
<option value="sae">WPA3 (SAE)</option> <option value="sae">WPA3 (SAE)</option>
@@ -469,7 +625,7 @@
type="password" type="password"
bind:value={config.wifi_password} bind:value={config.wifi_password}
placeholder="Enter password" placeholder="Enter password"
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"
/> />
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Changing the network requires re-entering the password. Changing the network requires re-entering the password.
@@ -489,7 +645,7 @@
type="text" type="text"
bind:value={dnsServersInput} bind:value={dnsServersInput}
placeholder="9.9.9.9, 149.112.112.112" placeholder="9.9.9.9, 149.112.112.112"
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"
/> />
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9, Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9,
@@ -500,64 +656,7 @@
</div> </div>
{/if} {/if}
<div class="border-t pt-4 mt-6 space-y-3"> <div class="border-t border-gray-200 pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Device Security</h3>
<div class="flex items-center">
<input
id="firewall_restrict_outbound"
type="checkbox"
bind:checked={config.firewall_restrict_outbound}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="firewall_restrict_outbound"
class="ml-2 block text-sm text-gray-700"
>
Restrict outbound traffic
</label>
</div>
<p class="text-xs text-gray-500">
Only allows DNS, DHCP, and HTTPS (port 443) outbound. Blocks all other
outbound connections on every interface (WiFi and cellular). Loopback and
hotspot traffic are always allowed. Changes take effect immediately.
</p>
{#if config.firewall_restrict_outbound}
<div>
<label
for="firewall_allowed_ports"
class="block text-sm font-medium text-gray-700 mb-1"
>
Additional Allowed Ports
</label>
<input
id="firewall_allowed_ports"
type="text"
value={config.firewall_allowed_ports
? config.firewall_allowed_ports.join(', ')
: ''}
oninput={(e) => {
const val = (e.target as HTMLInputElement).value.trim();
config!.firewall_allowed_ports =
val.length > 0
? val
.split(',')
.map((s) => parseInt(s.trim()))
.filter((n) => !isNaN(n) && n >= 1 && n <= 65535)
: null;
}}
placeholder="22, 80"
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">
Comma-separated TCP ports, e.g. 22, 80
</p>
</div>
{/if}
</div>
<div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4"> <h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings Analyzer Heuristic Settings
</h3> </h3>
@@ -567,7 +666,7 @@
id="imsi_requested" id="imsi_requested"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.imsi_requested} 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"> <label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
IMSI Requested Heuristic IMSI Requested Heuristic
@@ -579,7 +678,7 @@
id="connection_redirect_2g_downgrade" id="connection_redirect_2g_downgrade"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.connection_redirect_2g_downgrade} bind:checked={config.analyzers.connection_redirect_2g_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label <label
for="connection_redirect_2g_downgrade" for="connection_redirect_2g_downgrade"
@@ -594,7 +693,7 @@
id="lte_sib6_and_7_downgrade" id="lte_sib6_and_7_downgrade"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.lte_sib6_and_7_downgrade} bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label <label
for="lte_sib6_and_7_downgrade" for="lte_sib6_and_7_downgrade"
@@ -609,7 +708,7 @@
id="null_cipher" id="null_cipher"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.null_cipher} 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"> <label for="null_cipher" class="ml-2 block text-sm text-gray-700">
Null Cipher Heuristic Null Cipher Heuristic
@@ -621,7 +720,7 @@
id="nas_null_cipher" id="nas_null_cipher"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.nas_null_cipher} 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"> <label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
NAS Null Cipher Heuristic NAS Null Cipher Heuristic
@@ -633,7 +732,7 @@
id="incomplete_sib" id="incomplete_sib"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.incomplete_sib} 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"> <label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic Incomplete SIB Heuristic
@@ -645,7 +744,7 @@
id="test_analyzer" id="test_analyzer"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.test_analyzer} 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"> <label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisy!) Test Heuristic (noisy!)
@@ -656,7 +755,7 @@
id="diagnostic_analyzer" id="diagnostic_analyzer"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer} bind:checked={config.analyzers.diagnostic_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 <label
for="diagnostic_analyzer" for="diagnostic_analyzer"
@@ -668,6 +767,74 @@
</div> </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"> <div class="flex gap-2 pt-4">
<button <button
type="submit" type="submit"
@@ -700,7 +867,7 @@
</form> </form>
{#if message} {#if message}
<div <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-red-100 text-red-700'
: 'bg-green-100 text-green-700'}" : 'bg-green-100 text-green-700'}"
> >
@@ -5,8 +5,8 @@
<div class="flex flex-row justify-end gap-2"> <div class="flex flex-row justify-end gap-2">
<DeleteButton <DeleteButton
text="Delete ALL Recordings" text="Delete ALL Recordings"
prompt={`Are you sure you want to delete ALL recordings?`} prompt="Are you sure you want to delete ALL recordings?"
url={`/api/delete-all-recordings`} url="/api/delete-all-recordings"
name="all recodings" name="all recodings"
/> />
</div> </div>
@@ -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}
@@ -44,7 +44,7 @@
</script> </script>
<div <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} {#if current}
<div class="flex flex-row justify-between gap-2"> <div class="flex flex-row justify-between gap-2">
@@ -82,7 +82,7 @@
> >
</div> </div>
{#if entry.stop_reason} {#if entry.stop_reason}
<div class="bg-yellow-50 border border-yellow-300 rounded p-2 text-yellow-800 text-sm"> <div class="bg-yellow-50 border border-yellow-300 rounded-sm p-2 text-yellow-800 text-sm">
{entry.stop_reason} {entry.stop_reason}
</div> </div>
{/if} {/if}
@@ -100,7 +100,7 @@
/> />
{/if} {/if}
</div> </div>
<div class="border-b {analysis_visible ? '' : 'hidden'}"> <div class="border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} {manager} {current} /> <AnalysisView {entry} {manager} {current} />
</div> </div>
</div> </div>
@@ -16,7 +16,7 @@
{#if $screenIsLgUp} {#if $screenIsLgUp}
<table class="table-auto text-left table"> <table class="table-auto text-left table">
<thead> <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">ID</th>
<th class="p-2" scope="col">Started</th> <th class="p-2" scope="col">Started</th>
<th class="p-2" scope="col">Last Message</th> <th class="p-2" scope="col">Last Message</th>
@@ -36,7 +36,7 @@
} }
</script> </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">{entry.name}</td>
<td class="p-2">{date_formatter.format(entry.start_time)}</td> <td class="p-2">{date_formatter.format(entry.start_time)}</td>
<td class="p-2" <td class="p-2"
@@ -65,8 +65,8 @@
</td> </td>
{/if} {/if}
</tr> </tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}"> <tr class="{alternating_row_color} border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="9"> <td class="border-t border-gray-200 border-dashed p-2" colspan="9">
<AnalysisView {entry} {manager} {current} /> <AnalysisView {entry} {manager} {current} />
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -35,7 +35,7 @@
{#if shown} {#if shown}
<div <div
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md 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" flex flex-col p-2 drop-shadow-sm"
> >
<div class="flex justify-between items-center p-1"> <div class="flex justify-between items-center p-1">
<span class="text-2xl">{title}</span> <span class="text-2xl">{title}</span>
@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { type SystemStats } from '$lib/systemStats'; import { type SystemStats } from '$lib/systemStats';
import { gps_mode_label, GpsMode, type GpsData } from '$lib/utils.svelte';
let { let {
stats, stats,
gps_data = null,
gps_mode = GpsMode.Disabled,
}: { }: {
stats: SystemStats; stats: SystemStats;
gps_data?: GpsData | null;
gps_mode?: GpsMode;
} = $props(); } = $props();
const table_cell_classes = 'border p-1 lg:p-2';
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0); let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
let bar_color = $derived.by(() => { let bar_color = $derived.by(() => {
if (stats.battery_status === undefined) { if (stats.battery_status === undefined) {
@@ -36,31 +39,31 @@
</script> </script>
<div <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> <p class="text-xl mb-2">System Information</p>
<table class="table-auto border"> <table class="text-sm w-full">
<tbody> <tbody>
<tr class="border"> <tr class="border-b border-gray-200">
<th class={table_cell_classes}> Rayhunter Version </th> <td class="py-1 pr-4 text-gray-500 font-medium">Rayhunter Version</td>
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td> <td class="py-1">{stats.runtime_metadata.rayhunter_version}</td>
</tr> </tr>
<tr class="border"> <tr class="border-b border-gray-200">
<th class={table_cell_classes}> Storage </th> <td class="py-1 pr-4 text-gray-500 font-medium">Storage</td>
<td class={table_cell_classes}> <td class="py-1">
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats {stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
.disk_stats.available_size} available) .disk_stats.available_size} available)
</td> </td>
</tr> </tr>
<tr class="border-b"> <tr class="border-b border-gray-200">
<th class={table_cell_classes}> Memory (RAM) </th> <td class="py-1 pr-4 text-gray-500 font-medium">Memory (RAM)</td>
<td class={table_cell_classes}> <td class="py-1">
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used} Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td> </td>
</tr> </tr>
<tr class="border-b"> <tr class={gps_mode !== GpsMode.Disabled ? 'border-b border-gray-200' : ''}>
<th class={table_cell_classes}> Battery </th> <td class="py-1 pr-4 text-gray-500 font-medium">Battery</td>
<td class={table_cell_classes}> <td class="py-1">
<svg <svg
width="80" width="80"
height="30" height="30"
@@ -70,7 +73,6 @@
class="battery-icon" class="battery-icon"
> >
<title>{title_text}</title> <title>{title_text}</title>
<!-- Battery body -->
<rect <rect
class="fill-none stroke-neutral-800 stroke-2" class="fill-none stroke-neutral-800 stroke-2"
width="70" width="70"
@@ -78,7 +80,6 @@
rx="3" rx="3"
ry="3" ry="3"
/> />
<!-- Battery terminal -->
<rect <rect
class="fill-neutral-800" class="fill-neutral-800"
x="70" x="70"
@@ -88,7 +89,6 @@
rx="2" rx="2"
ry="2" ry="2"
/> />
<!-- Battery charge bar -->
<rect <rect
class={bar_color} class={bar_color}
x="2" x="2"
@@ -99,14 +99,12 @@
style="width: {battery_level * 0.66}px;" style="width: {battery_level * 0.66}px;"
/> />
{#if stats.battery_status && stats.battery_status.is_plugged_in} {#if stats.battery_status && stats.battery_status.is_plugged_in}
<!-- Lightning bolt icon -->
<path <path
class="fill-yellow-300 stroke-neutral-800 stroke-1" class="fill-yellow-300 stroke-neutral-800 stroke-1"
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z" d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
/> />
{/if} {/if}
{#if !stats.battery_status} {#if !stats.battery_status}
<!-- Question mark icon -->
<text <text
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]" class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
x="35" x="35"
@@ -116,6 +114,27 @@
</svg> </svg>
</td> </td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
@@ -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}
+11
View File
@@ -1,5 +1,6 @@
import { get_report, type AnalysisReport } from './analysis.svelte'; import { get_report, type AnalysisReport } from './analysis.svelte';
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte'; import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
import { GpsMode } from './utils.svelte';
interface JsonManifest { interface JsonManifest {
entries: JsonManifestEntry[]; entries: JsonManifestEntry[];
@@ -12,6 +13,8 @@ interface JsonManifestEntry {
last_message_time: string; last_message_time: string;
qmdl_size_bytes: number; qmdl_size_bytes: number;
stop_reason: string | null; stop_reason: string | null;
upload_time: string | null;
gps_mode: GpsMode | null;
} }
export class Manifest { export class Manifest {
@@ -59,6 +62,8 @@ export class ManifestEntry {
public analysis_status: AnalysisStatus | undefined = $state(undefined); public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined); public analysis_report: AnalysisReport | string | undefined = $state(undefined);
public stop_reason: string | undefined = $state(undefined); public stop_reason: string | undefined = $state(undefined);
public upload_time: Date | undefined = $state(undefined);
public gps_mode: GpsMode | undefined = $state(undefined);
constructor(json: JsonManifestEntry) { constructor(json: JsonManifestEntry) {
this.name = json.name; this.name = json.name;
@@ -70,6 +75,12 @@ export class ManifestEntry {
if (json.stop_reason) { if (json.stop_reason) {
this.stop_reason = 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 { get_readable_qmdl_size(): string {
+3 -1
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 // however, if we've reached the end of the input, that means we
// were given invalid nd-json // were given invalid nd-json
if (lines.length === 0) { 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,
});
} }
} }
} }
+65 -1
View File
@@ -16,6 +16,34 @@ export interface AnalyzerConfig {
export enum enabled_notifications { export enum enabled_notifications {
Warning = 'Warning', Warning = 'Warning',
LowBattery = 'LowBattery', 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 { export interface Config {
@@ -23,8 +51,9 @@ export interface Config {
ui_level: number; ui_level: number;
colorblind_mode: boolean; colorblind_mode: boolean;
key_input_mode: number; key_input_mode: number;
ntfy_url: string; ntfy_url: string | null;
enabled_notifications: enabled_notifications[]; enabled_notifications: enabled_notifications[];
auto_check_updates: boolean;
analyzers: AnalyzerConfig; analyzers: AnalyzerConfig;
min_space_to_start_recording_mb: number; min_space_to_start_recording_mb: number;
min_space_to_continue_recording_mb: number; min_space_to_continue_recording_mb: number;
@@ -35,6 +64,10 @@ export interface Config {
dns_servers: string[] | null; dns_servers: string[] | null;
firewall_restrict_outbound: boolean; firewall_restrict_outbound: boolean;
firewall_allowed_ports: number[] | null; firewall_allowed_ports: number[] | null;
webdav: WebdavConfig;
gps_mode: GpsMode;
gps_fixed_latitude: number | null;
gps_fixed_longitude: number | null;
} }
export interface WifiStatus { export interface WifiStatus {
@@ -139,6 +172,37 @@ export interface TimeResponse {
offset_seconds: number; 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> { export async function get_daemon_time(): Promise<TimeResponse> {
return JSON.parse(await req('GET', '/api/time')); 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());
}
+32 -6
View File
@@ -1,6 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte'; 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 ManifestTable from '$lib/components/ManifestTable.svelte';
import Card from '$lib/components/ManifestCard.svelte'; import Card from '$lib/components/ManifestCard.svelte';
import type { SystemStats } from '$lib/systemStats'; import type { SystemStats } from '$lib/systemStats';
@@ -12,6 +21,7 @@
import ActionErrors from '$lib/components/ActionErrors.svelte'; import ActionErrors from '$lib/components/ActionErrors.svelte';
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte'; import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
import LogView from '$lib/components/LogView.svelte'; import LogView from '$lib/components/LogView.svelte';
import UpdateNotice from '$lib/components/UpdateNotice.svelte';
let manager: AnalysisManager = new AnalysisManager(); let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false); let loaded = $state(false);
@@ -22,6 +32,9 @@
let update_error: string | undefined = $state(undefined); let update_error: string | undefined = $state(undefined);
let logview_shown: boolean = $state(false); let logview_shown: boolean = $state(false);
let config_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(() => { $effect(() => {
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
@@ -40,6 +53,16 @@
current_entry = new_manifest.current_entry; current_entry = new_manifest.current_entry;
system_stats = await get_system_stats(); 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; update_error = undefined;
loaded = true; loaded = true;
} catch (error) { } catch (error) {
@@ -57,7 +80,9 @@
<LogView bind:shown={logview_shown} /> <LogView bind:shown={logview_shown} />
<ConfigForm bind:shown={config_shown} /> <ConfigForm bind:shown={config_shown} />
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center"> <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/ --> <!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" /> <img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
@@ -204,7 +229,7 @@
<div class="m-4 xl:mx-8 flex flex-col gap-4"> <div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if update_error !== undefined} {#if update_error !== undefined}
<div <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"> <span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
<svg <svg
@@ -238,6 +263,7 @@
{/if} {/if}
<ActionErrors /> <ActionErrors />
<ClockDriftAlert /> <ClockDriftAlert />
<UpdateNotice status={update_status} />
{#if loaded} {#if loaded}
<div class="flex flex-col lg:flex-row gap-4"> <div class="flex flex-col lg:flex-row gap-4">
{#if current_entry} {#if current_entry}
@@ -249,7 +275,7 @@
/> />
{:else} {:else}
<div <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 <span
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600" class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
@@ -279,7 +305,7 @@
</div> </div>
</div> </div>
{/if} {/if}
<SystemStatsTable stats={system_stats!} /> <SystemStatsTable stats={system_stats!} {gps_data} {gps_mode} />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
@@ -295,7 +321,7 @@
type="checkbox" type="checkbox"
id="filter_threshold" id="filter_threshold"
bind:checked={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>
</div> </div>
-19
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;
+2 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
server: { server: {
@@ -26,7 +27,7 @@ export default defineConfig({
}, },
}, },
}, },
plugins: [sveltekit()], plugins: [tailwindcss(), sveltekit()],
build: { build: {
// Force everything into one HTML file. SvelteKit will still generate // Force everything into one HTML file. SvelteKit will still generate
// a lot of JS files but they are deadweight and will not be included // a lot of JS files but they are deadweight and will not be included
+27 -11
View File
@@ -28,6 +28,10 @@ key_input_mode = 0
# What notification types to enable. Does nothing if the above ntfy_url is not set. # What notification types to enable. Does nothing if the above ntfy_url is not set.
enabled_notifications = ["Warning", "LowBattery"] enabled_notifications = ["Warning", "LowBattery"]
# 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 # Disk Space Management
# Minimum free space (MB) required to start recording # Minimum free space (MB) required to start recording
min_space_to_start_recording_mb = 1 min_space_to_start_recording_mb = 1
@@ -43,17 +47,29 @@ wifi_enabled = false
# Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified. # Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified.
# dns_servers = ["9.9.9.9", "149.112.112.112"] # dns_servers = ["9.9.9.9", "149.112.112.112"]
# Device Security # WebDAV Upload
# Restrict outbound traffic to essential services only (DHCP, DNS, # If a [webdav] section is present, finished recordings (both the raw .qmdl file
# HTTPS, and replies to inbound connections). Applies to all outbound # and its .ndjson analysis output) are uploaded in the background to a WebDAV
# interfaces (WiFi and cellular). Loopback and hotspot bridge traffic # server once they've been closed for at least min_age_secs. After a successful
# are always allowed. Defaults to true (recommended). # upload the entry is either marked as uploaded in the manifest, or deleted
firewall_restrict_outbound = true # locally if delete_on_upload = true. With no [webdav] section, no upload
# worker runs.
# Additional TCP ports to allow outbound when the firewall is active. #
# DHCP (67-68), DNS (53), and HTTPS (443) are always allowed. # [webdav]
# Example: allow HTTP (80) and SSH (22). # url = "https://dav.example.com/rayhunter"
# firewall_allowed_ports = [80, 22] # # 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 # Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics # Enable/disable specific IMSI catcher detection heuristics
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
CONFIG="/data/rayhunter/config.toml"
case "$1" in
start)
if grep -q '^firewall_restrict_outbound = true' "$CONFIG" 2>/dev/null; then
iptables -F OUTPUT
iptables -A OUTPUT -o lo -j ACCEPT
for br in bridge0 br0; do
[ -d "/sys/class/net/$br" ] && iptables -A OUTPUT -o "$br" -j ACCEPT
done
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
iptables -A OUTPUT -j DROP
echo 0 > /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null
fi
;;
stop)
iptables -F OUTPUT
iptables -P OUTPUT ACCEPT
;;
esac
+46 -5
View File
@@ -15,12 +15,28 @@ Through web UI you can set:
- *Disable button control*: built-in power button of the device is not used by Rayhunter. - *Disable button control*: built-in power button of the device is not used by Rayhunter.
- *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. - *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. - **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/). - **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: - **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. - *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. - *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. - 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 ## 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. 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.
@@ -45,14 +61,39 @@ On the first detection of a crash, a diagnostic snapshot is saved to `/data/rayh
If recovery fails after 5 attempts, the status will change to **failed**. A reboot of the device will reset WiFi. 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: ## WebDAV Upload
```sh 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.
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
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
``` ```
## Device Security A few notes on behavior:
- **Restrict outbound traffic** limits what the device can send over the network. When enabled, only DNS, DHCP, and HTTPS traffic is allowed; everything else is blocked. This is enabled by default and prevents the device from phoning home to the carrier over cellular. If you need to allow additional ports (for example, port 80 for HTTP or port 22 for SSH), add them to the **Additional allowed ports** list. - **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). 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).
+5 -14
View File
@@ -41,30 +41,21 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
```bash ```bash
# For Orbic: # For Orbic:
./installer orbic --admin-password 'mypassword' ./installer orbic --admin-password 'mypassword'
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip' # Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
# may be required if different from the default. # may be required if different from the default.
# Optionally configure WiFi client mode during install (Orbic and Moxee only):
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
# Or install over USB if you want ADB and a root shell (not recommended for most users)
./installer orbic-usb
# For TP-Link: # For TP-Link:
./installer tplink ./installer tplink
``` ```
* On Verizon Orbic, the password is the one used to login to the device's admin menu, and the default is the WiFi password. * 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. * ***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 Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g` * TP-Link does not require an `--admin-password` parameter.
* 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.
For other devices, check `./installer --help` or the For other devices, check `./installer --help` or the
respective page in the sidebar under "Supported 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. 7. The installer will eventually tell you it's done, and the device will reboot.
+19
View File
@@ -27,6 +27,25 @@ Then you can build everything with:
./scripts/install-dev.sh orbic # replace 'orbic' with your device type ./scripts/install-dev.sh orbic # replace 'orbic' with your device type
``` ```
## Running the daemon on your PC
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 ## 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. If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
+16 -7
View File
@@ -27,21 +27,30 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL),
| 66 | 1700/2100 MHz (E-AWS) | | 66 | 1700/2100 MHz (E-AWS) |
| 71 | 600 MHz | | 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 ```bash
./installer moxee --admin-password 'mypassword' ./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.
`./installer moxee` is almost the same as `./installer orbic`, it just comes The Rayhunter UI will be available at <http://192.168.1.1:8080>.
with slightly better defaults that will give you more space for recordings.
<a name=shell></a>
## Obtaining a shell ## Obtaining a shell
```sh 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 ./installer util orbic-shell
``` ```
+32 -2
View File
@@ -42,11 +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 It's possible that many tutorials out there still refer to some of the old
installation routines. 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> <a name=shell></a>
## Obtaining a shell ## Obtaining a shell
After running the installer, there will not be a rootshell and ADB will not be After running the installer, there will not be a root shell and ADB will not be
enabled. Instead you can use `./installer util orbic-shell`. 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 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, obtain a root shell by running `adb shell` or `./installer util shell`. Then,
+1 -1
View File
@@ -55,7 +55,7 @@ You can copy the daemon and config files to the device using `netcat` or `adb pu
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. 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.
Setting `debug_mode = true` in `config.toml` runs the daemon without `/dev/diag`, so you can test the display and web UI without the hardware. 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 ### Autostart
+11 -3
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: 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.** * `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** * `6.2`: **One user reported it is working, not tested**
* `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332)) * `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332))
@@ -37,10 +38,17 @@ You can get your TP-Link M7350 from:
* [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313). * [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 * 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 ## Obtaining a shell
You can obtain a root shell with the following command: You can obtain a root shell with the following command:
+67 -67
View File
@@ -16,7 +16,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.57.1", "@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"eslint": "^10.2.1", "eslint": "^10.2.1",
@@ -25,7 +25,7 @@
"globals": "^17.5.0", "globals": "^17.5.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.1", "prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.55.4", "svelte": "^5.55.7",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
@@ -598,9 +598,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.57.1", "version": "2.60.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -609,7 +609,7 @@
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"acorn": "^8.14.1", "acorn": "^8.14.1",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"devalue": "^5.6.4", "devalue": "^5.8.1",
"esm-env": "^1.2.2", "esm-env": "^1.2.2",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"magic-string": "^0.30.5", "magic-string": "^0.30.5",
@@ -975,9 +975,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.10.1", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -985,9 +985,9 @@
} }
}, },
"node_modules/@tauri-apps/cli": { "node_modules/@tauri-apps/cli": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==",
"dev": true, "dev": true,
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"bin": { "bin": {
@@ -1001,23 +1001,23 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-arm64": "2.11.1",
"@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.11.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.11.1",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.11.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.11.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.11.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.11.1",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1" "@tauri-apps/cli-win32-x64-msvc": "2.11.1"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-arm64": { "node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1032,9 +1032,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-darwin-x64": { "node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1049,9 +1049,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1066,9 +1066,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-gnu": { "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1083,9 +1083,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-musl": { "node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1100,9 +1100,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": { "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1117,9 +1117,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-gnu": { "node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1134,9 +1134,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-musl": { "node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1151,9 +1151,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-arm64-msvc": { "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1168,9 +1168,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-ia32-msvc": { "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1185,9 +1185,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": { "node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1202,12 +1202,12 @@
} }
}, },
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.11.0"
} }
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
@@ -1690,9 +1690,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.4", "version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3045,9 +3045,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.55.4", "version": "5.55.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3060,7 +3060,7 @@
"aria-query": "5.3.1", "aria-query": "5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.6.4", "devalue": "^5.8.1",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.4", "esrap": "^2.2.4",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
+2 -2
View File
@@ -24,7 +24,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.57.1", "@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"eslint": "^10.2.1", "eslint": "^10.2.1",
@@ -33,7 +33,7 @@
"globals": "^17.5.0", "globals": "^17.5.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.1", "prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.55.4", "svelte": "^5.55.7",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "installer-gui" name = "installer-gui"
version = "0.10.2" version = "0.11.2"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "installer" name = "installer"
version = "0.10.2" version = "0.11.2"
edition = "2024" edition = "2024"
[lib] [lib]
+29 -18
View File
@@ -1,8 +1,6 @@
use std::path::Path; use std::path::Path;
use std::process::exit;
fn main() { fn main() {
println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN");
println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE"); println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE");
let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| { let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| {
// Default to firmware-devel for debug builds, firmware for release builds // Default to firmware-devel for debug builds, firmware for release builds
@@ -26,24 +24,37 @@ fn main() {
fn set_binary_var(include_dir: &Path, var: &str, file: &str) { fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
println!("cargo::rerun-if-env-changed={var}"); println!("cargo::rerun-if-env-changed={var}");
if std::env::var_os("NO_FIRMWARE_BIN").is_some() { if std::env::var_os(var).is_some() {
let out_dir = std::env::var("OUT_DIR").unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
let blank = Path::new(&out_dir).join("blank");
std::fs::write(&blank, []).unwrap();
println!("cargo::rustc-env={var}={}", blank.display());
return; return;
} }
if std::env::var_os(var).is_none() { let binary = include_dir.join(file);
let binary = include_dir.join(file); // We need to rerun the build script if the file starts appearing or disappearing, to emit the
println!("cargo::rerun-if-changed={}", binary.display()); // right warnings and change the envvar's value. We don't really need to rerun the build script
if !binary.exists() { // if the file changes contents.
println!( watch_file(&binary);
"cargo::error=Firmware binary {file} not present at {}", if binary.exists() {
binary.display()
);
exit(0);
}
println!("cargo::rustc-env={var}={}", binary.display()); println!("cargo::rustc-env={var}={}", binary.display());
} else {
println!(
"cargo::warning=Firmware binary {file} not present at {}; \
installers that need it will fail",
binary.display()
);
println!("cargo::rustc-env={var}=");
} }
} }
/// Rerun the build script if the file changes or it appears, or disappears.
///
/// Simply emitting rerun-if-changed for a nonexistent filepath (such as wpa_supplicant) will make
/// cargo recompile everything all the time. Therefore, if the file does not exist we need to watch
/// the first parent directory that does.
fn watch_file(mut file: &Path) {
while !file.exists()
&& let Some(parent) = file.parent()
{
file = parent;
}
println!("cargo::rerun-if-changed={}", file.display());
}
+16 -9
View File
@@ -47,20 +47,27 @@ pub async fn install_config<C: DeviceConnection>(
/// ///
/// Skips any binary that is already present on the device (e.g. provided by firmware), /// Skips any binary that is already present on the device (e.g. provided by firmware),
/// since those may be newer or better-integrated than the bundled versions. /// since those may be newer or better-integrated than the bundled versions.
pub async fn install_wifi_tools<C: DeviceConnection>( ///
conn: &mut C, /// In debug builds the wpa-supplicant binaries may not be bundled (build.rs sets the
wpa_supplicant: &[u8], /// env vars to empty in that case); when so, this is a no-op so devs don't have to
wpa_cli: &[u8], /// build wpa-supplicant just to install on Orbic.
iw: &[u8], pub async fn install_wifi_tools<C: DeviceConnection>(conn: &mut C) -> Result<()> {
) -> Result<()> { if env!("FILE_WPA_SUPPLICANT").is_empty() {
println!("wifi tools were not built into this installer, skipping");
return Ok(());
}
let tools: &[(&str, &str, &[u8])] = &[ let tools: &[(&str, &str, &[u8])] = &[
( (
"wpa_supplicant", "wpa_supplicant",
"/data/rayhunter/bin/wpa_supplicant", "/data/rayhunter/bin/wpa_supplicant",
wpa_supplicant, crate::get_file!("FILE_WPA_SUPPLICANT"),
), ),
("wpa_cli", "/data/rayhunter/bin/wpa_cli", wpa_cli), (
("iw", "/data/rayhunter/bin/iw", iw), "wpa_cli",
"/data/rayhunter/bin/wpa_cli",
crate::get_file!("FILE_WPA_CLI"),
),
("iw", "/data/rayhunter/bin/iw", crate::get_file!("FILE_IW")),
]; ];
for &(name, dest, payload) in tools { for &(name, dest, payload) in tools {
if device_has_binary(conn, name).await { if device_has_binary(conn, name).await {
+23
View File
@@ -0,0 +1,23 @@
#[cfg(debug_assertions)]
macro_rules! get_file {
($var:literal) => {{
let path = env!($var);
match ::std::fs::read(path) {
Ok(bytes) => bytes.leak() as &'static [u8],
Err(e) => panic!("Failed to read file for {}: {}", $var, e),
}
}};
}
#[cfg(not(debug_assertions))]
macro_rules! get_file {
($var:literal) => {{
const _: () = assert!(
!env!($var).is_empty(),
concat!($var, " was not bundled at build time"),
);
include_bytes!(env!($var)) as &'static [u8]
}};
}
pub(crate) use get_file;
+3
View File
@@ -6,6 +6,9 @@ use env_logger::Env;
use anyhow::bail; use anyhow::bail;
mod connection; mod connection;
mod files;
pub(crate) use files::*;
mod moxee; mod moxee;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
mod orbic; mod orbic;
+3 -16
View File
@@ -136,7 +136,7 @@ async fn force_debug_mode() -> Result<ADBUSBDevice> {
} }
async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> { async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
let rootshell_bin = include_bytes!(env!("FILE_ROOTSHELL")); let rootshell_bin = crate::get_file!("FILE_ROOTSHELL");
install_file(adb_device, "/bin/rootshell", rootshell_bin).await?; install_file(adb_device, "/bin/rootshell", rootshell_bin).await?;
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
@@ -151,7 +151,7 @@ async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
} }
async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Result<ADBUSBDevice> { async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Result<ADBUSBDevice> {
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
adb_at_syscmd( adb_at_syscmd(
&mut adb_device, &mut adb_device,
@@ -170,13 +170,7 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
device: &mut adb_device, device: &mut adb_device,
}; };
install_config(&mut conn, "orbic", reset_config).await?; install_config(&mut conn, "orbic", reset_config).await?;
install_wifi_tools( install_wifi_tools(&mut conn).await?;
&mut conn,
include_bytes!(env!("FILE_WPA_SUPPLICANT")),
include_bytes!(env!("FILE_WPA_CLI")),
include_bytes!(env!("FILE_IW")),
)
.await?;
} }
install_file( install_file(
@@ -191,15 +185,8 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
include_bytes!("../../dist/scripts/misc-daemon"), include_bytes!("../../dist/scripts/misc-daemon"),
) )
.await?; .await?;
install_file(
&mut adb_device,
"/etc/init.d/S01iptables",
include_bytes!("../../dist/scripts/S01iptables"),
)
.await?;
adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/rayhunter_daemon").await?; adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/rayhunter_daemon").await?;
adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/misc-daemon").await?; adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/misc-daemon").await?;
adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/S01iptables").await?;
println!("done"); println!("done");
print!("Waiting for reboot... "); print!("Waiting for reboot... ");
adb_at_syscmd(&mut adb_device, "shutdown -r -t 1 now").await?; adb_at_syscmd(&mut adb_device, "shutdown -r -t 1 now").await?;
+2 -22
View File
@@ -216,7 +216,7 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> { async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> {
let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?; let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
// Remount filesystem as read-write to allow modifications // Remount filesystem as read-write to allow modifications
// This is really only necessary for the Moxee Hotspot // This is really only necessary for the Moxee Hotspot
@@ -248,13 +248,7 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
) )
.await?; .await?;
install_wifi_tools( install_wifi_tools(&mut conn).await?;
&mut conn,
include_bytes!(env!("FILE_WPA_SUPPLICANT")),
include_bytes!(env!("FILE_WPA_CLI")),
include_bytes!(env!("FILE_IW")),
)
.await?;
install_config(&mut conn, "orbic", reset_config).await?; install_config(&mut conn, "orbic", reset_config).await?;
@@ -273,13 +267,6 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
false, false,
) )
.await?; .await?;
telnet_send_file(
addr,
"/etc/init.d/S01iptables",
include_bytes!("../../dist/scripts/S01iptables"),
false,
)
.await?;
telnet_send_command( telnet_send_command(
addr, addr,
@@ -302,13 +289,6 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
false, false,
) )
.await?; .await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/S01iptables",
"exit code 0",
false,
)
.await?;
println!("Installation complete. Rebooting device..."); println!("Installation complete. Rebooting device...");
telnet_send_command(addr, "shutdown -r -t 1 now", "", false) telnet_send_command(addr, "shutdown -r -t 1 now", "", false)
+1 -1
View File
@@ -29,7 +29,7 @@ pub async fn install() -> Result<()> {
run_command_expect(&mut adb, "mount -o remount,rw /", "exit code 0").await?; run_command_expect(&mut adb, "mount -o remount,rw /", "exit code 0").await?;
run_command_expect(&mut adb, "mkdir -p /data/rayhunter", "exit code 0").await?; run_command_expect(&mut adb, "mkdir -p /data/rayhunter", "exit code 0").await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
adb.write_file("/data/rayhunter/rayhunter-daemon", rayhunter_daemon_bin) adb.write_file("/data/rayhunter/rayhunter-daemon", rayhunter_daemon_bin)
.await?; .await?;
adb.write_file( adb.write_file(
+1 -1
View File
@@ -48,7 +48,7 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
) )
.await?; .await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
telnet_send_file( telnet_send_file(
addr, addr,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
+1 -1
View File
@@ -188,7 +188,7 @@ async fn tplink_run_install(
install_config(&mut conn, "tplink", reset_config).await?; install_config(&mut conn, "tplink", reset_config).await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
telnet_send_file( telnet_send_file(
addr, addr,
+1 -1
View File
@@ -148,7 +148,7 @@ async fn install_rayhunter_files(adb_device: &mut ADBUSBDevice) -> Result<()> {
adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?; adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?;
// Install rayhunter daemon binary with verification // Install rayhunter daemon binary with verification
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
install_file( install_file(
adb_device, adb_device,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
+1 -2
View File
@@ -26,7 +26,6 @@ pub async fn install(
Args { Args {
admin_ip, admin_ip,
admin_password, admin_password,
..
}: Args, }: Args,
) -> Result<()> { ) -> Result<()> {
wingtech_run_install(admin_ip, admin_password).await wingtech_run_install(admin_ip, admin_password).await
@@ -105,7 +104,7 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
) )
.await?; .await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
telnet_send_file( telnet_send_file(
addr, addr,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter" name = "rayhunter"
version = "0.10.2" version = "0.11.2"
edition = "2024" edition = "2024"
description = "Realtime cellular data decoding and analysis for IMSI catcher detection" description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
+1 -1
View File
@@ -414,7 +414,7 @@ impl Harness {
pub fn analyze_qmdl_messages(&mut self, container: MessagesContainer) -> Vec<AnalysisRow> { pub fn analyze_qmdl_messages(&mut self, container: MessagesContainer) -> Vec<AnalysisRow> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for maybe_qmdl_message in container.into_messages() { for maybe_qmdl_message in container.messages() {
self.packet_num += 1; self.packet_num += 1;
rows.push(AnalysisRow { rows.push(AnalysisRow {
+6 -6
View File
@@ -85,9 +85,9 @@ pub struct MessagesContainer {
} }
impl MessagesContainer { impl MessagesContainer {
pub fn into_messages(self) -> Vec<Result<Message, DiagParsingError>> { pub fn messages(&self) -> Vec<Result<Message, DiagParsingError>> {
let mut result = Vec::new(); let mut result = Vec::new();
for msg in self.messages { for msg in &self.messages {
for sub_msg in msg.data.split_inclusive(|&b| b == MESSAGE_TERMINATOR) { for sub_msg in msg.data.split_inclusive(|&b| b == MESSAGE_TERMINATOR) {
match hdlc_decapsulate(sub_msg, &CRC_CCITT) { match hdlc_decapsulate(sub_msg, &CRC_CCITT) {
Ok(data) => match Message::from_bytes((&data, 0)) { Ok(data) => match Message::from_bytes((&data, 0)) {
@@ -569,7 +569,7 @@ mod test {
let mut container = make_container(DataType::UserSpace, encapsulated1); let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(encapsulated2); container.messages.push(encapsulated2);
container.num_messages += 1; container.num_messages += 1;
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]); assert_eq!(container.messages(), vec![Ok(message1), Ok(message2)]);
} }
#[test] #[test]
@@ -579,7 +579,7 @@ mod test {
encapsulated1.data.extend(encapsulated2.data); encapsulated1.data.extend(encapsulated2.data);
encapsulated1.len += encapsulated2.len; encapsulated1.len += encapsulated2.len;
let container = make_container(DataType::UserSpace, encapsulated1); let container = make_container(DataType::UserSpace, encapsulated1);
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]); assert_eq!(container.messages(), vec![Ok(message1), Ok(message2)]);
} }
#[test] #[test]
@@ -593,7 +593,7 @@ mod test {
let mut container = make_container(DataType::UserSpace, encapsulated1); let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(encapsulated2); container.messages.push(encapsulated2);
container.num_messages += 1; container.num_messages += 1;
let result = container.into_messages(); let result = container.messages();
assert_eq!(result[0], Ok(message1)); assert_eq!(result[0], Ok(message1));
assert!(matches!( assert!(matches!(
result[1], result[1],
@@ -611,7 +611,7 @@ mod test {
let mut container = make_container(DataType::UserSpace, encapsulated1); let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(bad_encapsulation); container.messages.push(bad_encapsulation);
container.num_messages += 1; container.num_messages += 1;
let result = container.into_messages(); let result = container.messages();
assert_eq!(result[0], Ok(message1)); assert_eq!(result[0], Ok(message1));
assert!(matches!( assert!(matches!(
result[1], result[1],
+1 -1
View File
@@ -212,7 +212,7 @@ impl DiagDevice {
if container.data_type != DataType::UserSpace { if container.data_type != DataType::UserSpace {
continue; continue;
} }
return Ok(container.into_messages()); return Ok(container.messages());
} }
} }
+20 -2
View File
@@ -6,10 +6,11 @@ use crate::gsmtap::GsmtapMessage;
use chrono::prelude::*; use chrono::prelude::*;
use deku::prelude::*; use deku::prelude::*;
use pcap_file_tokio::pcapng::PcapNgWriter; use pcap_file_tokio::pcapng::PcapNgWriter;
use pcap_file_tokio::pcapng::blocks::enhanced_packet::EnhancedPacketBlock; use pcap_file_tokio::pcapng::blocks::enhanced_packet::{EnhancedPacketBlock, EnhancedPacketOption};
use pcap_file_tokio::pcapng::blocks::interface_description::InterfaceDescriptionBlock; use pcap_file_tokio::pcapng::blocks::interface_description::InterfaceDescriptionBlock;
use pcap_file_tokio::pcapng::blocks::section_header::{SectionHeaderBlock, SectionHeaderOption}; use pcap_file_tokio::pcapng::blocks::section_header::{SectionHeaderBlock, SectionHeaderOption};
use pcap_file_tokio::{Endianness, PcapError}; use pcap_file_tokio::{Endianness, PcapError};
use serde::Serialize;
use std::borrow::Cow; use std::borrow::Cow;
use thiserror::Error; use thiserror::Error;
use tokio::io::AsyncWrite; use tokio::io::AsyncWrite;
@@ -26,6 +27,15 @@ pub enum GsmtapPcapError {
Deku(#[from] DekuError), Deku(#[from] DekuError),
} }
#[derive(Serialize)]
pub struct GpsPoint {
pub unix_ts: i64,
#[serde(rename = "lat")]
pub latitude: f64,
#[serde(rename = "lon")]
pub longitude: f64,
}
pub struct GsmtapPcapWriter<T> pub struct GsmtapPcapWriter<T>
where where
T: AsyncWrite, T: AsyncWrite,
@@ -102,6 +112,7 @@ where
&mut self, &mut self,
msg: GsmtapMessage, msg: GsmtapMessage,
timestamp: Timestamp, timestamp: Timestamp,
gps: Option<&GpsPoint>,
) -> Result<(), GsmtapPcapError> { ) -> Result<(), GsmtapPcapError> {
let duration = timestamp let duration = timestamp
.to_datetime() .to_datetime()
@@ -137,14 +148,21 @@ where
data.extend(&ip_header.to_bytes()?); data.extend(&ip_header.to_bytes()?);
data.extend(&udp_header.to_bytes()?); data.extend(&udp_header.to_bytes()?);
data.extend(&msg_bytes); data.extend(&msg_bytes);
let mut options = vec![];
if let Some(p) = gps {
let comment = serde_json::to_string(p).expect("GpsPoint serialization cannot fail");
options.push(EnhancedPacketOption::Comment(Cow::Owned(comment)));
}
let packet = EnhancedPacketBlock { let packet = EnhancedPacketBlock {
interface_id: 0, interface_id: 0,
timestamp: duration, timestamp: duration,
original_len: data.len() as u32, original_len: data.len() as u32,
data: Cow::Owned(data), data: Cow::Owned(data),
options: vec![], options,
}; };
self.writer.write_pcapng_block(packet).await?; self.writer.write_pcapng_block(packet).await?;
self.ip_id = self.ip_id.wrapping_add(1); self.ip_id = self.ip_id.wrapping_add(1);
Ok(()) Ok(())
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rootshell" name = "rootshell"
version = "0.10.2" version = "0.11.2"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+3 -3
View File
@@ -52,10 +52,10 @@ build_wifi_tools() {
fi fi
if ! command -v arm-linux-musleabihf-gcc &> /dev/null; then if ! command -v arm-linux-musleabihf-gcc &> /dev/null; then
echo "Error: arm-linux-musleabihf-gcc not found." echo "Warning: Skipping building WiFi tools due to missing C crosscompiler."
echo "arm-linux-musleabihf-gcc not found."
echo "Install with: brew install FiloSottile/musl-cross/musl-cross" echo "Install with: brew install FiloSottile/musl-cross/musl-cross"
echo "(Required because the installer bundles wpa_supplicant, wpa_cli, and iw for orbic-family devices.)" return
exit 1
fi fi
echo "Building WiFi tools..." echo "Building WiFi tools..."
+3 -4
View File
@@ -39,10 +39,9 @@ cd "$BUILD_DIR/libnl-${LIBNL_VERSION}"
--enable-static \ --enable-static \
--disable-shared \ --disable-shared \
--disable-cli \ --disable-cli \
--disable-debug \ --disable-debug
> /dev/null 2>&1 make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" > /dev/null 2>&1 make install
make install > /dev/null 2>&1
echo "Building wpa_supplicant ${WPA_VERSION}..." echo "Building wpa_supplicant ${WPA_VERSION}..."
cd "$BUILD_DIR" cd "$BUILD_DIR"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "telcom-parser" name = "telcom-parser"
version = "0.10.2" version = "0.11.2"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html