26 Commits

Author SHA1 Message Date
Markus Unterwaditzer
4d54ea03e8 Remove some global reset styles in favor of explicit border colors, restore button cursors 2026-04-24 02:00:57 +02:00
Markus Unterwaditzer
2b427c64d7 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 01:58:53 +02:00
Markus Unterwaditzer
ed3ad389d0 downgrade tailwind for now, and fix build errors 2026-04-24 01:58:53 +02:00
dependabot[bot]
e2fd9de62d Bump the dependency-type group in /daemon/web with 18 updates
Bumps the dependency-type group in /daemon/web with 18 updates:

| 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.57.1` |
| [@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.0` |
| [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.0` |
| [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.4` |
| [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.2` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.46.0` | `8.58.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.2` | `8.0.8` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `3.2.4` | `4.1.4` |


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.57.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.57.1/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.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.37.0...v10.2.0)

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.0
- [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.0/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.4
- [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.4/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.2
- [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.2/packages/tailwindcss)

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

Updates `typescript-eslint` from 8.46.0 to 8.58.2
- [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.58.2/packages/typescript-eslint)

Updates `vite` from 7.3.2 to 8.0.8
- [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.8/packages/vite)

Updates `vitest` from 3.2.4 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/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 01:58:53 +02:00
Cooper Quintin
7daacb3b65 Revert "web: target older mobile browsers"
This reverts commit a8aae16fa1.
2026-04-23 09:16:28 -07:00
Cooper Quintin
4a9e9c507b ignore wifi tools builds 2026-04-23 09:16:28 -07:00
DeoJin
a8aae16fa1 web: target older mobile browsers 2026-04-22 12:03:17 -07:00
Ember
5fc6925d35 doc: document Wi-Fi auto-shutdown workaround for TMOHS1 and CT2MHS01 (#951)
Both devices ship with a Wi-Fi Standby timer that turns off the AP
after ~10 minutes with no clients, blocking remote access to Rayhunter
until a power cycle. Previous attempt (this PR's earlier commits) added
a Rayhunter config toggle to flip gWlanAutoShutdown in WCNSS_qcom_cfg.ini,
but the same setting is already exposed in each device's native admin UI
under Settings -> Sleep -> Wi-Fi Standby, so a code change is not needed.

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

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

* Fix workflow

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

* code changes for rust based wifi client mode docs next

* Doc changes & security fixes

* Added watchdog and recover if crash occurs for wifi.

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

* Add missing wifi and firewall module declarations

* cleaning up the code a bit

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

* fix to check diskspace

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

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

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

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

This reverts commit 944b369c4f.

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

* Moved to a wifi crate

* Add host route and arp_filter to prevent subnet collisions

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

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

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

* Update to new version of wifi-station

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

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

* Fix build script: use bash for parameter substitution

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

* Fix iw build: export PKG_CONFIG_LIBDIR as env var

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

* Point wifi-station to GitHub rev 97c579a

* add comment

* Update daemon/src/config.rs

Add decorators

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

* Update daemon/src/server.rs

add utopia doc support

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

* Update daemon/src/server.rs

add utopia doc support

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

* Update to wifi-station with utoipa doc strings

* add utoipa to wifi-station

* added WPA3 support

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

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

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

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

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

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

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

* update Cargo.lock for wifi-station e8ec5b4

* fix setup_timeout_server crypto provider install, apply rustfmt

* Update installer/src/connection.rs

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

* Update installer/src/orbic.rs

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

* apply rustfmt to AdbConnection::run_command

---------

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


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

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

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

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

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

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

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

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

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

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

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

**Implementation:**

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

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

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


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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 00:14:54 +02:00
72 changed files with 3846 additions and 5345 deletions

View File

@@ -8,4 +8,8 @@ ignore = [
# user-input. we could get rid of this warning by disabling the image
# dependency in adb-client.
"RUSTSEC-2024-0436",
# rustls-webpki 0.102.8 CRL Distribution Point flaw (via rustls-rustcrypto).
# Only affects dev builds, production firmware uses ring-tls.
# TODO: Remove once rustls-rustcrypto releases a version newer than 0.0.2-alpha.
"RUSTSEC-2026-0049",
]

View File

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

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

@@ -0,0 +1,41 @@
version: 2
updates:
# Rust dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
groups:
dependency-type:
patterns:
- "*"
# Python dependencies
- package-ecosystem: "pip"
directory: "/tools"
schedule:
interval: "weekly"
groups:
dependency-type:
patterns:
- "*"
# daemon/web Node.js dependencies
- package-ecosystem: "npm"
directory: "/daemon/web"
schedule:
interval: "weekly"
groups:
dependency-type:
patterns:
- "*"
# installer-gui Node.js dependencies
- package-ecosystem: "npm"
directory: "/installer-gui"
schedule:
interval: "weekly"
groups:
dependency-type:
patterns:
- "*"

View File

@@ -6,6 +6,7 @@
- [ ] Code has been linted and run through `cargo fmt`.
- [ ] If any new functionality has been added, unit tests were also added.
- [ ] [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md) has been read.
- [ ] Your pull request is fewer than ~400 lines of code.
You must check one of:
- [ ] No generative AI (including LLMs) tools were used to create this PR.

View File

@@ -11,6 +11,9 @@ env:
CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
FILE_WPA_SUPPLICANT: ../../wpa-supplicant/wpa_supplicant
FILE_WPA_CLI: ../../wpa-supplicant/wpa_cli
FILE_IW: ../../wpa-supplicant/iw
RUSTFLAGS: "-Dwarnings"
jobs:
@@ -301,6 +304,30 @@ jobs:
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
if-no-files-found: error
build_wpa_supplicant:
if: needs.files_changed.outputs.installer_changed == 'true'
needs:
- files_changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install cross-compiler
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
- name: Build wpa_supplicant (armv7)
run: CC=arm-linux-gnueabihf-gcc STRIP=arm-linux-gnueabihf-strip HOST=arm-linux-gnueabihf scripts/build-wpa-supplicant.sh
- uses: actions/upload-artifact@v4
with:
name: wpa-supplicant
path: |
tools/build-wpa-supplicant/out/wpa_supplicant
tools/build-wpa-supplicant/out/wpa_cli
tools/build-wpa-supplicant/out/iw
if-no-files-found: error
build_rayhunter:
if: needs.files_changed.outputs.daemon_needed == 'true'
needs:
@@ -314,27 +341,25 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Install ARM cross-compilation toolchain
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
- name: Build rayhunter-daemon (armv7)
- name: Build frontend
run: |
pushd daemon/web
npm install
npm run build
popd
# Run with -p so that cargo will select the minimum feature set for this package.
#
# Otherwise, it will consider the union of all requested features
# from all packages in the workspace. For example, if installer
# requires tokio with "full" feature, it will be included no matter
# what the feature selection in rayhunter-daemon is.
#
# https://github.com/rust-lang/cargo/issues/4463
CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
- name: Build rayhunter-daemon (armv7)
# Cross-compile inside messense/rust-musl-cross, which bundles an
# arm-linux-musleabihf cross gcc that aws-lc-sys needs.
run: |
mkdir -p "$HOME/.cargo-musl-cross"
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/work \
-v "$HOME/.cargo-musl-cross":/cargo-home \
-e CARGO_HOME=/cargo-home \
-w /work \
messense/rust-musl-cross:armv7-musleabihf \
cargo build-daemon-firmware
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon
@@ -349,6 +374,7 @@ jobs:
needs:
- build_rayhunter
- build_rootshell
- build_wpa_supplicant
- files_changed
- windows_installer_check_and_test
strategy:

1
.gitignore vendored
View File

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

View File

@@ -58,6 +58,8 @@ Otherwise:
manually test them. Our test coverage isn't great, but as new features are
added we are trying to prevent it from becoming worse.
- Please keep your contributions to less than approximately 400 lines of code not counting tests, (going slightly over is fine, we aren't dogmatic about it.) This is because we are not able to give quality code review to contributions larger than that and risk introducing bugs into the system. [There was a study showing 400 LOC is the max most humans can handle.](https://smartbear.com/learn/code-review/best-practices-for-peer-code-review/)
If you have any questions [feel free to open a discussion or chat with us on Mattermost.](https://efforg.github.io/rayhunter/support-feedback-community.html)
### Policy regarding AI-generated contributions:

530
Cargo.lock generated
View File

@@ -274,18 +274,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-compression"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-executor"
version = "1.13.3"
@@ -466,6 +454,28 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.8.4"
@@ -797,10 +807,11 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.23"
version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
@@ -935,6 +946,15 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "cmake"
version = "0.1.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0"
dependencies = [
"cc",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -957,23 +977,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
dependencies = [
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1761,10 +1764,16 @@ dependencies = [
]
[[package]]
name = "flate2"
version = "1.1.9"
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [
"crc32fast",
"miniz_oxide",
@@ -1823,6 +1832,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "funty"
version = "2.0.0"
@@ -1841,9 +1856,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
@@ -1856,9 +1871,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
@@ -1866,15 +1881,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
@@ -1883,9 +1898,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
@@ -1902,9 +1917,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
@@ -1913,21 +1928,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.32"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
@@ -1937,6 +1952,7 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
@@ -2077,10 +2093,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -2090,11 +2104,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
@@ -2507,7 +2519,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.57.0",
]
[[package]]
@@ -2521,9 +2533,9 @@ dependencies = [
[[package]]
name = "ico"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
@@ -2770,7 +2782,7 @@ dependencies = [
"md5 0.7.0",
"md5crypt",
"nusb",
"reqwest",
"reqwest 0.12.20",
"serde",
"sha2",
"termios",
@@ -2968,10 +2980,12 @@ checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
[[package]]
name = "js-sys"
version = "0.3.77"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -3156,12 +3170,6 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -3590,38 +3598,8 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.9.1",
"block2 0.6.2",
"libc",
"objc2 0.6.3",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
@@ -3643,45 +3621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.9.1",
"dispatch2",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
@@ -3719,28 +3659,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.9.1",
"block2 0.6.2",
"libc",
"objc2 0.6.3",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.3",
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2 0.6.3",
"objc2-core-foundation",
]
@@ -3770,28 +3688,6 @@ dependencies = [
"objc2-metal",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.3",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -3816,8 +3712,6 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
@@ -4160,6 +4054,12 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
@@ -4450,61 +4350,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.12",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.1",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.12",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "quote"
version = "1.0.40"
@@ -4696,7 +4541,6 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
name = "rayhunter"
version = "0.10.2"
dependencies = [
"async-compression",
"bytes",
"chrono",
"crc",
@@ -4712,6 +4556,7 @@ dependencies = [
"serde",
"serde_json",
"telcom-parser",
"tempfile",
"thiserror 1.0.69",
"tokio",
"utoipa",
@@ -4746,7 +4591,8 @@ dependencies = [
"libc",
"log",
"rayhunter",
"reqwest",
"reqwest 0.12.20",
"rustls-post-quantum",
"rustls-rustcrypto",
"serde",
"serde_json",
@@ -4756,7 +4602,9 @@ dependencies = [
"tokio-stream",
"tokio-util",
"toml 0.8.22",
"url",
"utoipa",
"wifi-station",
]
[[package]]
@@ -4857,7 +4705,6 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -4868,7 +4715,6 @@ dependencies = [
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
@@ -4877,6 +4723,39 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-util",
"tower",
"tower-http",
@@ -4886,7 +4765,6 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -4963,12 +4841,6 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -5006,14 +4878,15 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.28"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.3",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -5024,10 +4897,20 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-post-quantum"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0da3cd9229bac4fae1f589c8f875b3c891a058ddaa26eb3bde16b5e43dc174ce"
dependencies = [
"aws-lc-rs",
"rustls",
"rustls-webpki 0.103.10",
]
[[package]]
name = "rustls-rustcrypto"
version = "0.0.2-alpha"
@@ -5071,10 +4954,11 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.3"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -5491,7 +5375,7 @@ dependencies = [
"log",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-quartz-core 0.2.2",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall",
"wasm-bindgen",
@@ -5724,9 +5608,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.9.2"
version = "2.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bceb52453e507c505b330afe3398510e87f428ea42b6e76ecb6bd63b15965b5"
checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d"
dependencies = [
"anyhow",
"bytes",
@@ -5752,7 +5636,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -5775,9 +5659,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.5.1"
version = "2.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a924b6c50fe83193f0f8b14072afa7c25b7a72752a2a73d9549b463f5fe91a38"
checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [
"anyhow",
"cargo_toml",
@@ -5797,9 +5681,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.5.0"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1fe64c74cc40f90848281a90058a6db931eb400b60205840e09801ee30f190"
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -5824,9 +5708,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.5.0"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "260c5d2eb036b76206b9fca20b7be3614cfd21046c5396f7959e0e64a4b07f2f"
checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -5877,9 +5761,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.9.1"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926"
checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2"
dependencies = [
"cookie",
"dpi",
@@ -5902,9 +5786,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.9.1"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e"
dependencies = [
"gtk",
"http",
@@ -5912,7 +5796,6 @@ dependencies = [
"log",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"once_cell",
"percent-encoding",
"raw-window-handle",
@@ -5929,9 +5812,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.8.0"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
dependencies = [
"anyhow",
"brotli",
@@ -6122,21 +6005,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.45.0"
@@ -6359,9 +6227,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.6"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.9.1",
"bytes",
@@ -6706,48 +6574,32 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn 2.0.101",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.50"
version = "0.4.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -6755,31 +6607,31 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.101",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
@@ -6790,19 +6642,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.77"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6810,9 +6652,9 @@ dependencies = [
[[package]]
name = "webkit2gtk"
version = "2.0.1"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a"
checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
dependencies = [
"bitflags 1.3.2",
"cairo-rs",
@@ -6834,9 +6676,9 @@ dependencies = [
[[package]]
name = "webkit2gtk-sys"
version = "2.0.1"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c"
checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
@@ -6909,6 +6751,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
[[package]]
name = "wifi-station"
version = "0.10.1"
source = "git+https://github.com/BeigeBox/wifi-station?rev=e8ec5b4#e8ec5b491fa125bf5346b6aa84f13ed51de33044"
dependencies = [
"anyhow",
"chrono",
"log",
"serde",
"tokio",
"tokio-util",
"utoipa",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6931,7 +6787,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.48.0",
]
[[package]]
@@ -7476,9 +7332,9 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "wry"
version = "0.53.5"
version = "0.54.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2"
checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",

View File

@@ -113,8 +113,12 @@ async fn analyze_pcap(pcap_path: &str, show_skipped: bool) {
async fn analyze_qmdl(qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
let compressed = qmdl_path.ends_with(".gz");
let qmdl_reader = QmdlReader::new(qmdl_file, compressed, None);
let file_size = qmdl_file
.metadata()
.await
.expect("failed to get QMDL file metadata")
.len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin!(
qmdl_reader
.as_stream()
@@ -137,9 +141,8 @@ async fn pcapify(qmdl_path: &PathBuf) {
let qmdl_file = &mut File::open(&qmdl_path)
.await
.expect("failed to open qmdl file");
let compressed = qmdl_path.ends_with(".gz");
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, compressed, Some(qmdl_file_size as usize));
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
let mut pcap_path = qmdl_path.clone();
pcap_path.set_extension("pcapng");
let pcap_file = &mut File::create(&pcap_path)
@@ -194,7 +197,9 @@ async fn main() {
let name_str = name.to_str().unwrap();
let path = entry.path();
let path_str = path.to_str().unwrap();
if name_str.ends_with(".qmdl") || name_str.ends_with(".qmdl.gz") {
// instead of relying on the QMDL extension, can we check if a file is
// QMDL by inspecting the contents?
if name_str.ends_with(".qmdl") {
info!("**** Beginning analysis of {name_str}");
analyze_qmdl(path_str, args.show_skipped).await;
if args.pcapify {

View File

@@ -16,11 +16,12 @@ required-features = ["apidocs"]
[features]
default = ["rustcrypto-tls"]
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
ring-tls = ["reqwest/rustls-tls-webpki-roots"]
apidocs = ["dep:utoipa"]
pq-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-post-quantum"]
apidocs = ["dep:utoipa", "wifi-station/utoipa"]
[dependencies]
rayhunter = { path = "../lib" }
wifi-station = { git = "https://github.com/BeigeBox/wifi-station", rev = "e8ec5b4" }
toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] }
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
@@ -33,7 +34,7 @@ futures-macro = "0.3.30"
include_dir = "0.7.3"
chrono = { version = "0.4.31", features = ["serde"] }
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
futures = { version = "0.3.32", default-features = false, features = ["std"] }
futures = { version = "0.3.30", default-features = false }
serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.2"
@@ -41,5 +42,7 @@ async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
rustls-post-quantum = { version = "0.2.4", optional = true }
async-trait = "0.1.88"
utoipa = { version = "5.4.0", optional = true }
url = "2.5.4"

View File

@@ -10,6 +10,7 @@ use futures::TryStreamExt;
use log::{error, info};
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader;
use serde::Serialize;
use tokio::fs::File;
use tokio::io::{AsyncWriteExt, BufWriter};
@@ -134,7 +135,7 @@ async fn perform_analysis(
analyzer_config: &AnalyzerConfig,
) -> Result<(), String> {
info!("Opening QMDL and analysis file for {name}...");
let (analysis_file, qmdl_reader) = {
let (analysis_file, qmdl_file) = {
let mut qmdl_store = qmdl_store_lock.write().await;
let (entry_index, _) = qmdl_store
.entry_for_name(name)
@@ -143,17 +144,23 @@ async fn perform_analysis(
.clear_and_open_entry_analysis(entry_index)
.await
.map_err(|e| format!("{e:?}"))?;
let qmdl_reader = qmdl_store
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| format!("{e:?}"))?;
(analysis_file, qmdl_reader)
(analysis_file, qmdl_file)
};
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
.await
.map_err(|e| format!("{e:?}"))?;
let file_size = qmdl_file
.metadata()
.await
.expect("failed to get QMDL file metadata")
.len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin::pin!(
qmdl_reader
.as_stream()

View File

@@ -32,8 +32,24 @@ pub struct Config {
pub enabled_notifications: Vec<NotificationType>,
/// Vector containing the list of enabled analyzers
pub analyzers: AnalyzerConfig,
/// Minimum disk space required to start a recording
pub min_space_to_start_recording_mb: u64,
/// Minimum disk space required to continue a recording
pub min_space_to_continue_recording_mb: u64,
/// Wifi client SSID
pub wifi_ssid: Option<String>,
/// Wifi client password
pub wifi_password: Option<String>,
/// Wifi security type (wpa_psk or sae)
pub wifi_security: Option<wifi_station::SecurityType>,
/// Wifi client mode
pub wifi_enabled: bool,
/// Vector containing wifi client DNS servers
pub dns_servers: Option<Vec<String>>,
/// Wifi client firewall mode
pub firewall_restrict_outbound: bool,
/// Vector containing additional wifi client firewall ports to open
pub firewall_allowed_ports: Option<Vec<u16>>,
}
impl Default for Config {
@@ -51,20 +67,83 @@ impl Default for Config {
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
min_space_to_start_recording_mb: 1,
min_space_to_continue_recording_mb: 1,
wifi_ssid: None,
wifi_password: None,
wifi_security: None,
wifi_enabled: false,
dns_servers: None,
firewall_restrict_outbound: true,
firewall_allowed_ports: None,
}
}
}
impl Config {
pub fn wifi_config(&self) -> wifi_station::WifiConfig {
let (wpa_bin, hostapd_conf, ctrl_interface) = match self.device {
Device::Tmobile | Device::Wingtech => (
Some("/usr/sbin/wpa_supplicant".into()),
Some("/data/configs/hostapd.conf".into()),
None,
),
Device::Uz801 => (
Some("/system/bin/wpa_supplicant".into()),
Some("/data/misc/wifi/hostapd.conf".into()),
Some("/data/misc/wifi/sockets".into()),
),
_ => (None, None, None),
};
wifi_station::WifiConfig {
wifi_enabled: self.wifi_enabled,
dns_servers: self.dns_servers.clone(),
wifi_ssid: self.wifi_ssid.clone(),
wifi_password: self.wifi_password.clone(),
security_type: self.wifi_security,
wpa_supplicant_bin: wpa_bin.or_else(|| resolve_bin("wpa_supplicant")),
hostapd_conf,
ctrl_interface,
udhcpc_hook_path: Some("/data/rayhunter/udhcpc-hook.sh".into()),
dhcp_lease_path: Some("/data/rayhunter/dhcp_lease".into()),
wpa_conf_path: Some("/data/rayhunter/wpa_sta.conf".into()),
iw_bin: resolve_bin("iw"),
udhcpc_bin: resolve_bin("udhcpc"),
crash_log_dir: Some("/data/rayhunter/crash-logs".into()),
wakelock_name: Some("rayhunter".into()),
}
}
}
fn resolve_bin(name: &str) -> Option<String> {
let local = format!("/data/rayhunter/bin/{name}");
if std::path::Path::new(&local).exists() {
return Some(local);
}
None
}
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
where
P: AsRef<std::path::Path>,
{
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
let mut config = if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?
} else {
warn!("unable to read config file, using default config");
Ok(Config::default())
Config::default()
};
if let Some((ssid, security)) =
wifi_station::read_network_from_wpa_conf("/data/rayhunter/wpa_sta.conf")
{
config.wifi_ssid = Some(ssid);
config.wifi_security = Some(security);
} else {
config.wifi_ssid = None;
config.wifi_security = None;
}
config.wifi_password = None;
Ok(config)
}
pub struct Args {

View File

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

View File

@@ -63,7 +63,7 @@ pub struct DiagTask {
enum DiagState {
Recording {
qmdl_writer: Box<QmdlWriter<File>>,
qmdl_writer: QmdlWriter<File>,
analysis_writer: Box<AnalysisWriter>,
},
Stopped,
@@ -143,7 +143,7 @@ impl DiagTask {
DiskSpaceCheck::Failed => {}
}
let (qmdl_gz_file, analysis_file) = match qmdl_store.new_entry().await {
let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await {
Ok(files) => files,
Err(e) => {
let msg = format!("failed creating QMDL file entry: {e}");
@@ -152,7 +152,7 @@ impl DiagTask {
}
};
self.stop_current_recording().await;
let qmdl_writer = Box::new(QmdlWriter::new(qmdl_gz_file));
let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
{
Ok(writer) => Box::new(writer),
@@ -237,23 +237,13 @@ impl DiagTask {
let mut state = DiagState::Stopped;
std::mem::swap(&mut self.state, &mut state);
if let DiagState::Recording {
qmdl_writer,
analysis_writer,
..
analysis_writer, ..
} = state
{
match (qmdl_writer.close().await, analysis_writer.close().await) {
(Ok(()), Ok(())) => {}
(qmdl_result, analysis_result) => {
if let Err(err) = qmdl_result {
error!("failed to close QmdlWriter: {:?}", err);
}
if let Err(err) = analysis_result {
error!("failed to close AnalysisWriter: {:?}", err);
}
panic!();
}
}
analysis_writer
.close()
.await
.expect("failed to close analysis writer");
}
}
@@ -299,19 +289,17 @@ impl DiagTask {
self.stop(qmdl_store, Some(reason)).await;
return;
}
DiskSpaceCheck::Warning(mb) => {
if !self.low_space_warned {
self.low_space_warned = true;
warn!("Disk space low: {}MB remaining", mb);
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Disk space low: {}MB free", mb),
Some(Duration::from_secs(30)),
))
.await
.ok();
}
DiskSpaceCheck::Warning(mb) if !self.low_space_warned => {
self.low_space_warned = true;
warn!("Disk space low: {}MB remaining", mb);
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Disk space low: {}MB free", mb),
Some(Duration::from_secs(30)),
))
.await
.ok();
}
_ => {}
}
@@ -325,13 +313,13 @@ impl DiagTask {
}
debug!(
"total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_uncompressed_bytes
qmdl_writer.total_written
);
let index = qmdl_store
.current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
if let Err(e) = qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_uncompressed_bytes)
.update_entry_qmdl_size(index, qmdl_writer.total_written)
.await
{
let reason = format!("failed to update manifest (disk full?): {e}");

92
daemon/src/firewall.rs Normal file
View File

@@ -0,0 +1,92 @@
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(())
}

View File

@@ -1,9 +1,11 @@
pub mod analysis;
pub mod battery;
pub mod config;
pub mod crypto_provider;
pub mod diag;
pub mod display;
pub mod error;
pub mod firewall;
pub mod key_input;
pub mod notifications;
pub mod pcap;

View File

@@ -1,16 +1,17 @@
mod analysis;
mod battery;
mod config;
mod crypto_provider;
mod diag;
mod display;
mod error;
mod firewall;
mod key_input;
mod notifications;
mod pcap;
mod qmdl_store;
mod server;
mod stats;
use std::net::SocketAddr;
use std::sync::Arc;
@@ -22,10 +23,11 @@ use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
set_config, set_time_offset, test_notification,
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
};
use crate::stats::{get_qmdl_manifest, get_system_stats};
use wifi_station::WifiStatus;
use analysis::{
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
@@ -70,6 +72,8 @@ fn get_router() -> AppRouter {
.route("/api/config", get(get_config))
.route("/api/config", post(set_config))
.route("/api/test-notification", post(test_notification))
.route("/api/wifi-status", get(get_wifi_status))
.route("/api/wifi-scan", post(scan_wifi))
.route("/api/time", get(get_time))
.route("/api/time-offset", post(set_time_offset))
.route("/api/debug/display-state", post(debug_set_display_state))
@@ -173,12 +177,7 @@ fn run_shutdown_thread(
async fn main() -> Result<(), RayhunterError> {
rayhunter::init_logging(log::LevelFilter::Info);
#[cfg(feature = "rustcrypto-tls")]
{
rustls_rustcrypto::provider()
.install_default()
.expect("Couldn't install rustcrypto provider");
}
crate::crypto_provider::install_default();
let args = parse_args();
@@ -240,7 +239,7 @@ async fn run_with_config(
info!("Starting UI");
let update_ui = match &config.device {
Device::Orbic => display::orbic::update_ui,
Device::Orbic | Device::Moxee => display::orbic::update_ui,
Device::Tplink => display::tplink::update_ui,
Device::Tmobile => display::tmobile::update_ui,
Device::Wingtech => display::wingtech::update_ui,
@@ -288,6 +287,15 @@ async fn run_with_config(
config.enabled_notifications.clone(),
);
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
wifi_station::run_wifi_client(
&task_tracker,
&config.wifi_config(),
shutdown_token.clone(),
wifi_status.clone(),
);
firewall::apply(&config).await;
let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
@@ -297,6 +305,8 @@ async fn run_with_config(
analysis_sender: analysis_tx,
daemon_restart_token: restart_token.clone(),
ui_update_sender: Some(ui_update_tx),
wifi_status,
wifi_scan_lock: tokio::sync::Mutex::new(()),
});
run_server(&task_tracker, state, shutdown_token.clone()).await;

View File

@@ -10,6 +10,8 @@ use thiserror::Error;
use tokio::sync::mpsc::{self, error::TryRecvError};
use tokio_util::task::TaskTracker;
pub const DEFAULT_NOTIFICATION_TIMEOUT: u64 = 10; //seconds
#[derive(Error, Debug)]
pub enum NotificationError {
#[error("HTTP request failed: {0}")]
@@ -56,6 +58,7 @@ struct NotificationStatus {
pub struct NotificationService {
url: Option<String>,
timeout: u64,
tx: mpsc::Sender<Notification>,
rx: mpsc::Receiver<Notification>,
}
@@ -63,7 +66,12 @@ pub struct NotificationService {
impl NotificationService {
pub fn new(url: Option<String>) -> Self {
let (tx, rx) = mpsc::channel(10);
Self { url, tx, rx }
Self {
url,
timeout: DEFAULT_NOTIFICATION_TIMEOUT,
tx,
rx,
}
}
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
@@ -76,8 +84,14 @@ pub async fn send_notification(
http_client: &reqwest::Client,
url: &str,
message: String,
timeout: u64,
) -> Result<(), NotificationError> {
let response = http_client.post(url).body(message).send().await?;
let response = http_client
.post(url)
.body(message)
.timeout(Duration::from_secs(timeout))
.send()
.await?;
if response.status().is_success() {
Ok(())
@@ -151,7 +165,13 @@ pub fn run_notification_worker(
}
}
match send_notification(&http_client, &url, notification.message.clone()).await
match send_notification(
&http_client,
&url,
notification.message.clone(),
notification_service.timeout,
)
.await
{
Ok(()) => {
notification.last_sent = Some(Instant::now());
@@ -203,10 +223,7 @@ mod tests {
}
async fn setup_test_server() -> (Arc<Mutex<Vec<String>>>, String) {
#[cfg(feature = "rustcrypto-tls")]
{
let _ = rustls_rustcrypto::provider().install_default();
}
crate::crypto_provider::install_default();
let received_messages = Arc::new(Mutex::new(Vec::new()));
let test_state = TestServerState {
@@ -230,12 +247,53 @@ mod tests {
(received_messages, url)
}
async fn setup_timeout_server(timeout: u64) -> String {
crate::crypto_provider::install_default();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move {
// Accept the connection but don't respond in the timeout
let (_socket, _addr) = listener.accept().await.unwrap();
tokio::time::sleep(Duration::from_secs(timeout * 2)).await;
});
tokio::time::sleep(Duration::from_millis(100)).await;
url
}
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
drop(sender);
tracker.close();
tracker.wait().await;
}
#[tokio::test]
async fn test_send_notification_times_out() {
let timeout: u64 = 2;
let url = setup_timeout_server(timeout).await;
let http_client = reqwest::Client::new();
let result = send_notification(
&http_client,
&url,
"test warning message".to_string(),
timeout,
)
.await;
match result {
Err(NotificationError::RequestFailed(reqwest_error)) => {
println!("error = {:?}", reqwest_error);
assert!(reqwest_error.is_timeout());
}
_ => assert!(false),
}
}
#[tokio::test]
async fn test_notification_worker_sends_message() {
let (received_messages, url) = setup_test_server().await;

View File

@@ -45,20 +45,23 @@ pub async fn get_pcap(
StatusCode::NOT_FOUND,
format!("couldn't find manifest entry with name {qmdl_name}"),
))?;
if entry.uncompressed_qmdl_size_bytes == 0 {
if entry.qmdl_size_bytes == 0 {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string(),
));
}
let qmdl_reader = qmdl_store
let qmdl_size_bytes = entry.qmdl_size_bytes;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
// the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes)
let (reader, writer) = duplex(1024);
tokio::spawn(async move {
if let Err(e) = generate_pcap_data(writer, qmdl_reader).await {
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
error!("failed to generate PCAP: {e:?}");
}
});
@@ -68,7 +71,11 @@ pub async fn get_pcap(
Ok((headers, body).into_response())
}
pub async fn generate_pcap_data<R, W>(writer: W, mut reader: QmdlReader<R>) -> Result<(), Error>
pub async fn generate_pcap_data<R, W>(
writer: W,
qmdl_file: R,
qmdl_size_bytes: usize,
) -> Result<(), Error>
where
W: AsyncWrite + Unpin + Send,
R: AsyncRead + Unpin,
@@ -76,6 +83,7 @@ where
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
pcap_writer.write_iface_header().await?;
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
while let Some(container) = reader.get_next_messages_container().await? {
if container.data_type != DataType::UserSpace {
continue;

View File

@@ -4,7 +4,6 @@ use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use log::{info, warn};
use rayhunter::qmdl::QmdlReader;
use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -58,10 +57,8 @@ pub struct ManifestEntry {
/// The system time when the last message was recorded to the file
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub last_message_time: Option<DateTime<Local>>,
/// The size of the uncompressed QMDL data in bytes. Previously this was
/// called `qmdl_size_bytes`, so alias it for backwards compatibility.
#[serde(alias = "qmdl_size_bytes")]
pub uncompressed_qmdl_size_bytes: usize,
/// The size of the QMDL file in bytes
pub qmdl_size_bytes: usize,
/// The rayhunter daemon version which generated the file
pub rayhunter_version: Option<String>,
/// The OS which created the file
@@ -70,8 +67,6 @@ pub struct ManifestEntry {
pub arch: Option<String>,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub compressed: bool,
}
impl ManifestEntry {
@@ -82,22 +77,17 @@ impl ManifestEntry {
name: format!("{}", now.timestamp()),
start_time: now,
last_message_time: None,
uncompressed_qmdl_size_bytes: 0,
qmdl_size_bytes: 0,
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
stop_reason: None,
compressed: true,
}
}
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name);
if self.compressed {
filepath.set_extension("qmdl.gz");
} else {
filepath.set_extension("qmdl");
}
filepath.set_extension("qmdl");
filepath
}
@@ -163,9 +153,8 @@ impl RecordingStore {
}
// Does a best-effort attempt to recover the manifest from a directory of
// QMDL files. We expect these files to be named like "<timestamp>.qmdl"
// or "<timestamp>.qmdl.gz", and skip any files which don't match that
// pattern.
// QMDL files. We expect these files to be named like "<timestamp>.qmdl",
// and skip any files which don't match that pattern.
pub async fn recover<P>(path: P) -> Result<Self, RecordingStoreError>
where
P: AsRef<Path>,
@@ -185,14 +174,11 @@ impl RecordingStore {
continue;
};
let (stem, compressed) = if filename.ends_with(".qmdl") {
(filename.trim_end_matches(".qmdl"), false)
} else if filename.ends_with(".qmdl.gz") {
(filename.trim_end_matches(".qmdl.gz"), true)
} else {
if !filename.ends_with(".qmdl") {
continue;
};
}
let stem = filename.trim_end_matches(".qmdl");
let Ok(start_timestamp) = stem.parse::<i64>() else {
warn!("QMDL file has invalid name {os_filename:?}, skipping");
continue;
@@ -219,10 +205,9 @@ impl RecordingStore {
info!("successfully recovered QMDL entry {os_filename:?}!");
manifest_entries.push(ManifestEntry {
name: stem.to_string(),
compressed,
start_time: start_time.into(),
last_message_time: Some(last_message_time.into()),
uncompressed_qmdl_size_bytes: metadata.size() as usize,
qmdl_size_bytes: metadata.size() as usize,
rayhunter_version: None,
system_os: None,
arch: None,
@@ -231,7 +216,7 @@ impl RecordingStore {
}
// sort chronologically
manifest_entries.sort_by(|a, b| a.start_time.cmp(&b.start_time));
manifest_entries.sort_by_key(|a| a.start_time);
let mut store = RecordingStore {
path: path.as_ref().to_path_buf(),
@@ -280,19 +265,11 @@ impl RecordingStore {
}
// Returns the corresponding QMDL file for a given entry
pub async fn open_entry_qmdl(
&self,
entry_index: usize,
) -> Result<QmdlReader<File>, RecordingStoreError> {
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index];
let file = File::open(entry.get_qmdl_filepath(&self.path))
File::open(entry.get_qmdl_filepath(&self.path))
.await
.map_err(RecordingStoreError::ReadFileError)?;
Ok(QmdlReader::new(
file,
entry.compressed,
Some(entry.uncompressed_qmdl_size_bytes),
))
.map_err(RecordingStoreError::ReadFileError)
}
// Returns the corresponding QMDL file for a given entry
@@ -337,7 +314,7 @@ impl RecordingStore {
entry_index: usize,
size_bytes: usize,
) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].uncompressed_qmdl_size_bytes = size_bytes;
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
self.manifest.entries[entry_index].last_message_time =
Some(rayhunter::clock::get_adjusted_now());
self.write_manifest().await
@@ -513,10 +490,7 @@ mod tests {
.entry_for_name(&store.manifest.entries[entry_index].name)
.unwrap();
assert!(entry.last_message_time.is_some());
assert_eq!(
store.manifest.entries[entry_index].uncompressed_qmdl_size_bytes,
1000
);
assert_eq!(store.manifest.entries[entry_index].qmdl_size_bytes, 1000);
assert_eq!(
RecordingStore::read_manifest(dir.path()).await.unwrap(),
store.manifest

View File

@@ -14,8 +14,7 @@ use log::{error, warn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::fs::write;
use tokio::io::copy;
use tokio::io::duplex;
use tokio::io::{AsyncReadExt, copy, duplex};
use tokio::sync::RwLock;
use tokio::sync::mpsc::Sender;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
@@ -26,6 +25,7 @@ use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config;
use crate::diag::DiagDeviceCtrlMessage;
use crate::display::DisplayState;
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore;
@@ -38,6 +38,8 @@ pub struct ServerState {
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_token: CancellationToken,
pub ui_update_sender: Option<Sender<DisplayState>>,
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
pub wifi_scan_lock: tokio::sync::Mutex<()>,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
@@ -65,7 +67,7 @@ pub async fn get_qmdl(
StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {qmdl_idx}"),
))?;
let qmdl_reader = qmdl_store
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|err| {
@@ -74,15 +76,14 @@ pub async fn get_qmdl(
format!("error opening QMDL file: {err}"),
)
})?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
let headers = [
(CONTENT_TYPE, "application/octet-stream"),
(
CONTENT_LENGTH,
&entry.uncompressed_qmdl_size_bytes.to_string(),
),
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
];
let body = Body::from_stream(qmdl_reader.as_stream());
let body = Body::from_stream(qmdl_stream);
Ok((headers, body).into_response())
}
@@ -136,7 +137,9 @@ pub async fn serve_static(
pub async fn get_config(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Config>, (StatusCode, String)> {
Ok(Json(state.config.clone()))
let mut config = state.config.clone();
config.wifi_password = None;
Ok(Json(config))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
@@ -159,7 +162,12 @@ pub async fn set_config(
State(state): State<Arc<ServerState>>,
Json(config): Json<Config>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let config_str = toml::to_string_pretty(&config).map_err(|err| {
let mut config_to_write = config.clone();
config_to_write.wifi_ssid = None;
config_to_write.wifi_password = None;
config_to_write.wifi_security = None;
let config_str = toml::to_string_pretty(&config_to_write).map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to serialize config as TOML: {err}"),
@@ -173,6 +181,8 @@ pub async fn set_config(
)
})?;
wifi_station::update_wpa_conf(&config.wifi_config()).await;
// Trigger daemon restart after writing config
state.daemon_restart_token.cancel();
Ok((
@@ -211,20 +221,25 @@ pub async fn test_notification(
let http_client = reqwest::Client::new();
let message = "Test notification from Rayhunter".to_string();
crate::notifications::send_notification(&http_client, url, message)
.await
.map(|()| {
(
StatusCode::OK,
"Test notification sent successfully".to_string(),
)
})
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to send test notification: {e}"),
)
})
crate::notifications::send_notification(
&http_client,
url,
message,
DEFAULT_NOTIFICATION_TIMEOUT,
)
.await
.map(|()| {
(
StatusCode::OK,
"Test notification sent successfully".to_string(),
)
})
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to send test notification: {e}"),
)
})
}
/// Response for GET /api/time
@@ -310,21 +325,21 @@ pub async fn get_zip(
Path(entry_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned();
let (entry_index, compressed) = {
let (entry_index, qmdl_size_bytes) = {
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find entry with name {qmdl_idx}"),
))?;
if entry.uncompressed_qmdl_size_bytes == 0 {
if entry.qmdl_size_bytes == 0 {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string(),
));
}
(entry_index, entry.compressed)
(entry_index, entry.qmdl_size_bytes)
};
let qmdl_store_lock = state.qmdl_store_lock.clone();
@@ -337,18 +352,22 @@ pub async fn get_zip(
// Add QMDL file
{
let extension = if compressed { "qmdl.gz" } else { "qmdl" };
let entry = ZipEntryBuilder::new(
format!("{qmdl_idx}.{extension}").into(),
Compression::Stored,
);
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let qmdl_store = qmdl_store_lock.read().await;
let mut qmdl_reader = qmdl_store.open_entry_qmdl(entry_index).await?;
copy(&mut qmdl_reader, &mut entry_writer).await?;
let mut qmdl_file = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
copy(&mut qmdl_file, &mut entry_writer).await?;
entry_writer.into_inner().close().await?;
}
@@ -358,10 +377,17 @@ pub async fn get_zip(
ZipEntryBuilder::new(format!("{qmdl_idx}.pcapng").into(), Compression::Stored);
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let qmdl_store = qmdl_store_lock.read().await;
let qmdl_reader = qmdl_store.open_entry_qmdl(entry_index).await?;
let qmdl_file_for_pcap = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
if let Err(e) = generate_pcap_data(&mut entry_writer, qmdl_reader).await {
if let Err(e) =
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
{
// if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL.
error!("Failed to generate PCAP: {e:?}");
@@ -385,6 +411,55 @@ pub async fn get_zip(
Ok((headers, body).into_response())
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/wifi-status",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = wifi_station::WifiStatus)
),
summary = "Get wifi status",
description = "Show the status of the wifi client."
))]
pub async fn get_wifi_status(
State(state): State<Arc<ServerState>>,
) -> Json<wifi_station::WifiStatus> {
let status = state.wifi_status.read().await;
Json(status.clone())
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/wifi-scan",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Scan success", body = inline(Vec<wifi_station::WifiNetwork>), content_type = "application/json"),
(status = StatusCode::TOO_MANY_REQUESTS, description = "Scan already in progress"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Scan failed"),
),
summary = "Wifi SSID scan",
description = "Poll for a list of available wifi networks. Returns an array of WifiNetwork objects."
))]
pub async fn scan_wifi(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Vec<wifi_station::WifiNetwork>>, (StatusCode, String)> {
let _guard = state.wifi_scan_lock.try_lock().map_err(|_| {
(
StatusCode::TOO_MANY_REQUESTS,
"WiFi scan already in progress".to_string(),
)
})?;
let networks = wifi_station::scan_wifi_networks(wifi_station::STA_IFACE)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("WiFi scan failed: {e}"),
)
})?;
Ok(Json(networks))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/debug/display-state",
@@ -489,6 +564,8 @@ mod tests {
analysis_sender: analysis_tx,
daemon_restart_token: CancellationToken::new(),
ui_update_sender: None,
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
wifi_scan_lock: tokio::sync::Mutex::new(()),
})
}
@@ -521,10 +598,7 @@ mod tests {
assert_eq!(
filenames,
vec![
format!("{entry_name}.qmdl.gz"),
format!("{entry_name}.pcapng"),
]
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
);
}
}

View File

@@ -81,11 +81,12 @@ impl DiskStats {
let free_kb = (stat.f_bfree as u64 * block_size / 1024) as usize;
let available_kb = (stat.f_bavail as u64 * block_size / 1024) as usize;
let used_kb = total_kb.saturating_sub(free_kb);
let used_percent = if stat.f_blocks > 0 {
format!("{}%", (stat.f_blocks - stat.f_bfree) * 100 / stat.f_blocks)
} else {
"0%".to_string()
};
let used_percent = format!(
"{}%",
((stat.f_blocks - stat.f_bfree) * 100)
.checked_div(stat.f_blocks)
.unwrap_or(0)
);
Ok(Self {
partition: qmdl_path.to_string(),

View File

@@ -22,7 +22,7 @@ export default ts.config(
},
},
{
files: ['**/*.svelte'],
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
@@ -48,6 +48,11 @@ export default ts.config(
format: ['snake_case'],
},
],
// these rules should eventually be enabled, just disabled them to
// make dependency upgrades easier.
'svelte/prefer-svelte-reactivity': 'off',
'svelte/require-each-key': 'off',
'svelte/no-navigation-without-resolve': 'off',
},
}
);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@
{#if action_errors.length > 0}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
>
<div class="flex flex-row justify-between">

View File

@@ -55,7 +55,7 @@
{#if show_alert}
<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">
<svg

View File

@@ -1,5 +1,14 @@
<script lang="ts">
import { get_config, set_config, test_notification, type Config } from '../utils.svelte';
import {
get_config,
set_config,
test_notification,
get_wifi_status,
scan_wifi_networks,
type Config,
type WifiStatus,
type WifiNetwork,
} from '../utils.svelte';
import Modal from './Modal.svelte';
let { shown = $bindable() }: { shown: boolean } = $props();
@@ -12,13 +21,20 @@
let messageType = $state<'success' | 'error' | null>(null);
let testMessage = $state('');
let testMessageType = $state<'success' | 'error' | null>(null);
let wifiStatus = $state<WifiStatus | null>(null);
let wifiStatusTimer = $state<ReturnType<typeof setInterval> | null>(null);
let scanning = $state(false);
let scanResults = $state<WifiNetwork[]>([]);
let dnsServersInput = $state('');
async function load_config() {
try {
loading = true;
config = await get_config();
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
message = '';
messageType = null;
poll_wifi_status();
} catch (error) {
message = `Failed to load config: ${error}`;
messageType = 'error';
@@ -30,6 +46,15 @@
async function save_config() {
if (!config) return;
const trimmed = dnsServersInput.trim();
config.dns_servers =
trimmed.length > 0
? trimmed
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
: null;
try {
saving = true;
await set_config(config);
@@ -44,6 +69,49 @@
}
}
async function poll_wifi_status() {
if (wifiStatusTimer) clearInterval(wifiStatusTimer);
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
wifiStatusTimer = setInterval(async () => {
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
}, 5000);
}
let scanError = $state('');
async function do_scan() {
scanning = true;
scanError = '';
try {
scanResults = await scan_wifi_networks();
} catch (error) {
scanResults = [];
scanError = `Scan failed: ${error}`;
} finally {
scanning = false;
}
}
function select_network(network: WifiNetwork) {
if (config) {
config.wifi_ssid = network.ssid;
config.wifi_password = '';
config.wifi_security =
network.security === 'WPA3' || network.security === 'WPA3 (transition)'
? 'sae'
: 'wpa_psk';
scanResults = [];
}
}
async function send_test_notification() {
try {
testingNotification = true;
@@ -64,6 +132,16 @@
if (shown && !config) {
load_config();
}
if (!shown && wifiStatusTimer) {
clearInterval(wifiStatusTimer);
wifiStatusTimer = null;
}
return () => {
if (wifiStatusTimer) {
clearInterval(wifiStatusTimer);
wifiStatusTimer = null;
}
};
});
</script>
@@ -86,7 +164,7 @@
<select
id="ui_level"
bind:value={config.ui_level}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Invisible mode</option>
<option value={1}>1 - Subtle mode (colored line)</option>
@@ -110,7 +188,7 @@
<select
id="key_input_mode"
bind:value={config.key_input_mode}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Disable button control</option>
<option value={1}>1 - Double-tap power button to start new recording</option
@@ -124,7 +202,7 @@
id="colorblind_mode"
type="checkbox"
bind:checked={config.colorblind_mode}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
Colorblind Mode
@@ -132,7 +210,7 @@
</div>
</div>
<div class="border-t pt-4 mt-6 space-y-3">
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
<div>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
@@ -143,7 +221,7 @@
id="ntfy_url"
type="url"
bind:value={config.ntfy_url}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
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">
Test button below uses the saved configuration URL, not the input above
@@ -181,7 +259,7 @@
</button>
{#if testMessage}
<div
class="mt-2 p-2 rounded text-sm {testMessageType === 'error'
class="mt-2 p-2 rounded-sm text-sm {testMessageType === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}"
>
@@ -225,7 +303,7 @@
</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>
<div>
@@ -240,7 +318,7 @@
type="number"
min="1"
bind:value={config.min_space_to_start_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will not start if less than this amount of disk space is free
@@ -259,7 +337,7 @@
type="number"
min="1"
bind:value={config.min_space_to_continue_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will stop automatically if disk space drops below this level
@@ -267,7 +345,219 @@
</div>
</div>
<div class="border-t pt-4 mt-6">
{#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
<p class="text-xs text-gray-500">
Connect the device to an existing WiFi network for internet access (e.g.
notifications, remote access). The hotspot AP stays running alongside
WiFi client mode.
</p>
<div class="flex items-center">
<input
id="wifi_enabled"
type="checkbox"
bind:checked={config.wifi_enabled}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
Enable WiFi
</label>
</div>
<p class="text-xs text-gray-500">
Unchecking stops WiFi without clearing saved credentials.
</p>
{#if wifiStatus && config.wifi_enabled}
{#if wifiStatus.state === 'connected'}
<p class="text-xs text-green-600">
Connected to "{wifiStatus.ssid}" ({wifiStatus.ip})
</p>
{:else if wifiStatus.state === 'connecting'}
<p class="text-xs text-amber-600">Connecting...</p>
{:else if wifiStatus.state === 'recovering'}
<p class="text-xs text-amber-600">Recovering connection...</p>
{:else if wifiStatus.state === 'dataPathDead'}
<p class="text-xs text-amber-600">
Data path stalled, attempting recovery...
</p>
{:else if wifiStatus.state === 'failed'}
<p class="text-xs text-red-600">
Failed: {wifiStatus.error}
</p>
{/if}
{/if}
<div>
<label
for="wifi_ssid"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Network Name (SSID)
</label>
<div class="flex gap-2">
<input
id="wifi_ssid"
type="text"
bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<button
type="button"
onclick={do_scan}
disabled={scanning}
class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border border-gray-300 rounded-md"
>
{scanning ? 'Scanning...' : 'Scan'}
</button>
</div>
</div>
{#if scanError}
<p class="text-xs text-red-600">{scanError}</p>
{/if}
{#if scanResults.length > 0}
<div
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y divide-gray-200"
>
{#each scanResults as network}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex justify-between"
onclick={() => select_network(network)}
>
<span>{network.ssid}</span>
<span class="text-gray-400"
>{network.signal_dbm} dBm &middot; {network.security}</span
>
</button>
{/each}
</div>
{/if}
{#if config.wifi_ssid}
<div>
<label
for="wifi_security"
class="block text-sm font-medium text-gray-700 mb-1"
>
Security Type
</label>
<select
id="wifi_security"
bind:value={config.wifi_security}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
>
<option value="wpa_psk">WPA2 (WPA-PSK)</option>
<option value="sae">WPA3 (SAE)</option>
</select>
</div>
{/if}
<div>
<label
for="wifi_password"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Password
</label>
<input
id="wifi_password"
type="password"
bind:value={config.wifi_password}
placeholder="Enter password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Changing the network requires re-entering the password.
</p>
</div>
{#if config.wifi_ssid}
<div>
<label
for="dns_servers"
class="block text-sm font-medium text-gray-700 mb-1"
>
DNS Servers
</label>
<input
id="dns_servers"
type="text"
bind:value={dnsServersInput}
placeholder="9.9.9.9, 149.112.112.112"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9,
149.112.112.112 (Quad9).
</p>
</div>
{/if}
</div>
{/if}
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<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-sm"
/>
<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-hidden 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 border-gray-200 pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings
</h3>
@@ -277,7 +567,7 @@
id="imsi_requested"
type="checkbox"
bind:checked={config.analyzers.imsi_requested}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
IMSI Requested Heuristic
@@ -289,7 +579,7 @@
id="connection_redirect_2g_downgrade"
type="checkbox"
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label
for="connection_redirect_2g_downgrade"
@@ -304,7 +594,7 @@
id="lte_sib6_and_7_downgrade"
type="checkbox"
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label
for="lte_sib6_and_7_downgrade"
@@ -319,7 +609,7 @@
id="null_cipher"
type="checkbox"
bind:checked={config.analyzers.null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
Null Cipher Heuristic
@@ -331,7 +621,7 @@
id="nas_null_cipher"
type="checkbox"
bind:checked={config.analyzers.nas_null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
NAS Null Cipher Heuristic
@@ -343,7 +633,7 @@
id="incomplete_sib"
type="checkbox"
bind:checked={config.analyzers.incomplete_sib}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic
@@ -355,7 +645,7 @@
id="test_analyzer"
type="checkbox"
bind:checked={config.analyzers.test_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisy!)
@@ -366,7 +656,7 @@
id="diagnostic_analyzer"
type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label
for="diagnostic_analyzer"
@@ -410,7 +700,7 @@
</form>
{#if message}
<div
class="mt-4 p-3 rounded {messageType === 'error'
class="mt-4 p-3 rounded-sm {messageType === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}"
>

View File

@@ -5,8 +5,8 @@
<div class="flex flex-row justify-end gap-2">
<DeleteButton
text="Delete ALL Recordings"
prompt={`Are you sure you want to delete ALL recordings?`}
url={`/api/delete-all-recordings`}
prompt="Are you sure you want to delete ALL recordings?"
url="/api/delete-all-recordings"
name="all recodings"
/>
</div>

View File

@@ -44,7 +44,7 @@
</script>
<div
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
class="{status_row_color} {status_border_color} drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
>
{#if current}
<div class="flex flex-row justify-between gap-2">
@@ -82,7 +82,7 @@
>
</div>
{#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}
</div>
{/if}
@@ -100,7 +100,7 @@
/>
{/if}
</div>
<div class="border-b {analysis_visible ? '' : 'hidden'}">
<div class="border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} {manager} {current} />
</div>
</div>

View File

@@ -16,7 +16,7 @@
{#if $screenIsLgUp}
<table class="table-auto text-left table">
<thead>
<tr class="bg-gray-100 drop-shadow">
<tr class="bg-gray-100 drop-shadow-sm">
<th class="p-2" scope="col">ID</th>
<th class="p-2" scope="col">Started</th>
<th class="p-2" scope="col">Last Message</th>

View File

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

View File

@@ -9,9 +9,11 @@
}: { shown: boolean; title: string; children: Snippet } = $props();
onMount(() => {
window.addEventListener('scroll', () => {
const handler = () => {
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
});
};
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
});
$effect(() => {
@@ -33,7 +35,7 @@
{#if shown}
<div
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
flex flex-col p-2 drop-shadow"
flex flex-col p-2 drop-shadow-sm"
>
<div class="flex justify-between items-center p-1">
<span class="text-2xl">{title}</span>

View File

@@ -6,7 +6,7 @@
stats: SystemStats;
} = $props();
const table_cell_classes = 'border p-1 lg:p-2';
const table_cell_classes = 'border border-gray-200 p-1 lg:p-2';
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
let bar_color = $derived.by(() => {
@@ -36,29 +36,29 @@
</script>
<div
class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
class="flex-1 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
>
<p class="text-xl mb-2">System Information</p>
<table class="table-auto border">
<table class="table-auto border border-gray-200">
<tbody>
<tr class="border">
<tr class="border border-gray-200">
<th class={table_cell_classes}> Rayhunter Version </th>
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
</tr>
<tr class="border">
<tr class="border border-gray-200">
<th class={table_cell_classes}> Storage </th>
<td class={table_cell_classes}>
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
.disk_stats.available_size} available)
</td>
</tr>
<tr class="border-b">
<tr class="border-b border-gray-200">
<th class={table_cell_classes}> Memory (RAM) </th>
<td class={table_cell_classes}>
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td>
</tr>
<tr class="border-b">
<tr class="border-b border-gray-200">
<th class={table_cell_classes}> Battery </th>
<td class={table_cell_classes}>
<svg

View File

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

View File

@@ -19,6 +19,7 @@ export enum enabled_notifications {
}
export interface Config {
device: string;
ui_level: number;
colorblind_mode: boolean;
key_input_mode: number;
@@ -27,6 +28,34 @@ export interface Config {
analyzers: AnalyzerConfig;
min_space_to_start_recording_mb: number;
min_space_to_continue_recording_mb: number;
wifi_ssid: string | null;
wifi_password: string | null;
wifi_security: 'wpa_psk' | 'sae' | null;
wifi_enabled: boolean;
dns_servers: string[] | null;
firewall_restrict_outbound: boolean;
firewall_allowed_ports: number[] | null;
}
export interface WifiStatus {
state: string;
ssid?: string;
ip?: string;
error?: string;
}
export interface WifiNetwork {
ssid: string;
signal_dbm: number;
security: string;
}
export async function get_wifi_status(): Promise<WifiStatus> {
return JSON.parse(await req('GET', '/api/wifi-status'));
}
export async function scan_wifi_networks(): Promise<WifiNetwork[]> {
return JSON.parse(await req('POST', '/api/wifi-scan'));
}
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {

View File

@@ -57,7 +57,9 @@
<LogView bind:shown={logview_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/ -->
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
<div class="flex flex-row gap-4">
@@ -204,7 +206,7 @@
<div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if update_error !== undefined}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
>
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
<svg
@@ -249,7 +251,7 @@
/>
{:else}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
>
<span
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
@@ -295,7 +297,7 @@
type="checkbox"
id="filter_threshold"
bind:checked={filter_threshold}
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
</div>

View File

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

View File

@@ -1,5 +1,6 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
server: {
@@ -26,7 +27,7 @@ export default defineConfig({
},
},
},
plugins: [sveltekit()],
plugins: [tailwindcss(), sveltekit()],
build: {
// Force everything into one HTML file. SvelteKit will still generate
// a lot of JS files but they are deadweight and will not be included

23
dist/config.toml.in vendored
View File

@@ -24,7 +24,7 @@ ui_level = 1
key_input_mode = 0
# If set, attempts to send a notification to the url when a new warning is triggered
ntfy_url = ""
# ntfy_url = "https://ntfy.sh/your-topic"
# What notification types to enable. Does nothing if the above ntfy_url is not set.
enabled_notifications = ["Warning", "LowBattery"]
@@ -34,6 +34,27 @@ min_space_to_start_recording_mb = 1
# Minimum free space (MB) to continue recording (stops if below this)
min_space_to_continue_recording_mb = 1
# WiFi Client Mode
# Toggle wifi_enabled to connect the device to an existing WiFi network.
# Credentials are stored separately in wpa_sta.conf and managed via the web UI.
wifi_enabled = false
# DNS servers to use when WiFi client mode is active.
# Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified.
# dns_servers = ["9.9.9.9", "149.112.112.112"]
# Device Security
# Restrict outbound traffic to essential services only (DHCP, DNS,
# HTTPS, and replies to inbound connections). Applies to all outbound
# interfaces (WiFi and cellular). Loopback and hotspot bridge traffic
# are always allowed. Defaults to true (recommended).
firewall_restrict_outbound = true
# Additional TCP ports to allow outbound when the firewall is active.
# DHCP (67-68), DNS (53), and HTTPS (443) are always allowed.
# Example: allow HTTP (80) and SSH (22).
# firewall_allowed_ports = [80, 22]
# Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details

24
dist/scripts/S01iptables vendored Normal file
View File

@@ -0,0 +1,24 @@
#!/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

View File

@@ -14,6 +14,7 @@
- [Re-analyzing recordings](./reanalyzing.md)
- [How we analyze a capture](./analyzing-a-capture.md)
- [Supported devices](./supported-devices.md)
- [Porting to new devices](./porting.md)
- [Orbic/Kajeet RC400L](./orbic.md)
- [TP-Link M7350](./tplink-m7350.md)
- [TP-Link M7310](./tplink-m7310.md)

View File

@@ -21,4 +21,38 @@ Through web UI you can set:
- *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.
- 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.
## WiFi Client Mode
On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network.
- **Enable WiFi** turns WiFi client mode on or off. Disabling it does not erase saved credentials.
- **Scan** searches for nearby networks. Select one from the dropdown, or type an SSID manually.
- **Password** is required for WPA/WPA2 networks. The password is stored separately from `config.toml` (in `wpa_sta.conf` on the device) and is never exposed through the API.
- **DNS Servers** lets you override the DNS servers used when connected. Defaults to `9.9.9.9` and `149.112.112.112` (Quad9) if not set.
After saving, the connection status will show **connecting**, **connected** (with the assigned IP address), or **failed** (with an error message). If the connection fails, check that the SSID and password are correct and that the network is in range.
### Crash Recovery
The WiFi kernel module (`wlan.ko`) can occasionally crash or unload, taking both the hotspot and client interfaces down with it. Rayhunter includes a watchdog that detects this and automatically reloads the module, restarts the hotspot, and reconnects to the configured network. During recovery the WiFi status will show **recovering**.
On the first detection of a crash, a diagnostic snapshot is saved to `/data/rayhunter/crash-logs/` on the device. You can pull these logs with `adb pull /data/rayhunter/crash-logs/` and inspect them to understand what went wrong. Each log contains:
- **dmesg** output (kernel messages). Look for backtraces, `BUG:`/`Oops:` lines, or `wlan`/`wcnss` errors. The kernel ring buffer is small and gets overwritten quickly, so crash details may already be gone if the crash happened well before detection.
- **/proc/modules** snapshot. If `wlan` is absent, the module fully unloaded. If present but interfaces are gone, the driver is stuck.
- **ip addr** output confirming which network interfaces existed at snapshot time.
- **ps** output showing which WiFi-related processes (`hostapd`, `wpa_supplicant`, `wland`) were still running.
If recovery fails after 5 attempts, the status will change to **failed**. A reboot of the device will reset WiFi.
You can also configure WiFi during installation:
```sh
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
```
## Device Security
- **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.
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

View File

@@ -44,6 +44,9 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
# 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

View File

@@ -22,6 +22,10 @@ pay more than 30 USD for such a device (without shipping).
| Wifi 5Ghz | a/ac/ax |
| Wifi 6 | 🮱 |
## WiFi client mode
The Orbic's QCA6174 radio supports running the hotspot and connecting to an external WiFi network at the same time. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for setup.
## Two kinds of installers
The orbic's installation routine underwent many different changes:

88
doc/porting.md Normal file
View File

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

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -19,6 +19,8 @@ You can access this UI in one of two ways:
On the **Orbic**, you can find the WiFi network password by going to the Orbic's menu > 2.4 GHz WIFI Info > Enter > find the 8-character password next to the lock 🔒 icon.
On the **TP-Link**, you can find the WiFi network password by going to the TP-Link's menu > Advanced > Wireless > Basic Settings.
If [WiFi client mode](./configuration.md#wifi-client-mode) is enabled, you can also reach the web UI from any device on that network at `http://<device-ip>:8080`.
* **Connect over USB (Orbic):** Connect your device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit <http://localhost:8080>.
* For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the `releases/platform-tools/` folder to somewhere else in your path or you can install it manually.
* You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -16,27 +16,27 @@
"tauri": "tauri"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.53.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/kit": "^2.57.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2",
"eslint": "^9.38.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.53.6",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.46.2",
"vite": "^6.0.3"
"eslint-plugin-svelte": "^3.17.0",
"globals": "^17.5.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.55.4",
"svelte-check": "^4.4.6",
"typescript": "~6.0.3",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.9"
}
}

View File

@@ -17,6 +17,11 @@ fn main() {
.join(&profile);
set_binary_var(&include_dir, "FILE_ROOTSHELL", "rootshell");
set_binary_var(&include_dir, "FILE_RAYHUNTER_DAEMON", "rayhunter-daemon");
let wpa_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tools/build-wpa-supplicant/out");
set_binary_var(&wpa_dir, "FILE_WPA_SUPPLICANT", "wpa_supplicant");
set_binary_var(&wpa_dir, "FILE_WPA_CLI", "wpa_cli");
set_binary_var(&wpa_dir, "FILE_IW", "iw");
}
fn set_binary_var(include_dir: &Path, var: &str, file: &str) {

View File

@@ -43,6 +43,47 @@ pub async fn install_config<C: DeviceConnection>(
Ok(())
}
/// Install wifi tools (wpa_supplicant, wpa_cli, iw) to /data/rayhunter/bin.
///
/// 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.
pub async fn install_wifi_tools<C: DeviceConnection>(
conn: &mut C,
wpa_supplicant: &[u8],
wpa_cli: &[u8],
iw: &[u8],
) -> Result<()> {
let tools: &[(&str, &str, &[u8])] = &[
(
"wpa_supplicant",
"/data/rayhunter/bin/wpa_supplicant",
wpa_supplicant,
),
("wpa_cli", "/data/rayhunter/bin/wpa_cli", wpa_cli),
("iw", "/data/rayhunter/bin/iw", iw),
];
for &(name, dest, payload) in tools {
if device_has_binary(conn, name).await {
println!("{name} already on device, skipping");
} else {
conn.write_file(dest, payload).await?;
conn.run_command(&format!("chmod +x {dest}")).await?;
}
}
Ok(())
}
async fn device_has_binary<C: DeviceConnection>(conn: &mut C, name: &str) -> bool {
// `command -v` is a POSIX shell builtin, so it works on minimal busybox firmware
// even when /usr/bin/which is absent.
conn.run_command(&format!(
"\"command -v {name} >/dev/null 2>&1 && echo FOUND || echo MISSING\""
))
.await
.map(|out| out.contains("FOUND"))
.unwrap_or(false)
}
/// Check if a directory exists using a DeviceConnection
pub async fn dir_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
conn.run_command(&format!("test -d '{path}' && echo exists || echo missing"))
@@ -172,7 +213,13 @@ impl TelnetConnection {
impl DeviceConnection for TelnetConnection {
async fn run_command(&mut self, command: &str) -> Result<String> {
crate::util::telnet_send_command_with_output(self.addr, command, self.wait_for_prompt).await
crate::util::telnet_send_command_with_output(
self.addr,
command,
self.wait_for_prompt,
std::time::Duration::from_secs(10),
)
.await
}
async fn write_file(&mut self, path: &str, content: &[u8]) -> Result<()> {

View File

@@ -13,7 +13,7 @@ use sha2::{Digest, Sha256};
use tokio::time::sleep;
use crate::RAYHUNTER_DAEMON_INIT;
use crate::connection::{DeviceConnection, install_config};
use crate::connection::{DeviceConnection, install_config, install_wifi_tools};
use crate::output::{print, println};
use crate::util::open_usb_device;
@@ -53,8 +53,15 @@ pub struct AdbConnection<'a> {
}
impl DeviceConnection for AdbConnection<'_> {
/// Runs through /bin/rootshell so commands execute as root (install_wifi_tools needs
/// chmod on root-owned files). setup_rootshell must have succeeded before an
/// AdbConnection is created; callers in this module (setup_rayhunter) enforce that
/// ordering.
async fn run_command(&mut self, command: &str) -> Result<String> {
adb_command(self.device, &["sh", "-c", command])
adb_command(
self.device,
&["/bin/rootshell", "-c", &format!("\"{command}\"")],
)
}
async fn write_file(&mut self, path: &str, content: &[u8]) -> Result<()> {
@@ -146,7 +153,11 @@ async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Result<ADBUSBDevice> {
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
adb_at_syscmd(&mut adb_device, "mkdir -p /data/rayhunter").await?;
adb_at_syscmd(
&mut adb_device,
"mkdir -p /data/rayhunter/scripts /data/rayhunter/bin",
)
.await?;
install_file(
&mut adb_device,
"/data/rayhunter/rayhunter-daemon",
@@ -159,6 +170,13 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
device: &mut adb_device,
};
install_config(&mut conn, "orbic", reset_config).await?;
install_wifi_tools(
&mut conn,
include_bytes!(env!("FILE_WPA_SUPPLICANT")),
include_bytes!(env!("FILE_WPA_CLI")),
include_bytes!(env!("FILE_IW")),
)
.await?;
}
install_file(
@@ -173,8 +191,15 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
include_bytes!("../../dist/scripts/misc-daemon"),
)
.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/misc-daemon").await?;
adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/S01iptables").await?;
println!("done");
print!("Waiting for reboot... ");
adb_at_syscmd(&mut adb_device, "shutdown -r -t 1 now").await?;

View File

@@ -8,7 +8,9 @@ use serde::Deserialize;
use tokio::time::sleep;
use crate::RAYHUNTER_DAEMON_INIT;
use crate::connection::{TelnetConnection, install_config, setup_data_directory};
use crate::connection::{
TelnetConnection, install_config, install_wifi_tools, setup_data_directory,
};
use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password};
use crate::output::{eprintln, print, println};
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
@@ -229,6 +231,15 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
let mut conn = TelnetConnection::new(addr, false);
setup_data_directory(&mut conn, data_dir).await?;
// Ensure bin and scripts directories exist under the data dir (via symlink)
telnet_send_command(
addr,
"mkdir -p /data/rayhunter/scripts /data/rayhunter/bin",
"exit code 0",
false,
)
.await?;
telnet_send_file(
addr,
"/data/rayhunter/rayhunter-daemon",
@@ -237,6 +248,14 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
)
.await?;
install_wifi_tools(
&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?;
telnet_send_file(
@@ -254,6 +273,13 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
false,
)
.await?;
telnet_send_file(
addr,
"/etc/init.d/S01iptables",
include_bytes!("../../dist/scripts/S01iptables"),
false,
)
.await?;
telnet_send_command(
addr,
@@ -276,6 +302,13 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
false,
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/S01iptables",
"exit code 0",
false,
)
.await?;
println!("Installation complete. Rebooting device...");
telnet_send_command(addr, "shutdown -r -t 1 now", "", false)

View File

@@ -94,5 +94,11 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
reboot_device(addr, "reboot", &admin_ip).await;
println!();
println!("Note: by default the TMOHS1 shuts off Wi-Fi after 10 minutes with no clients,");
println!("which blocks remote access to Rayhunter until you power cycle. To keep");
println!("Wi-Fi always on, open http://{admin_ip}/ -> Settings -> Sleep and set");
println!("Wi-Fi Standby to \"Always on\". See doc/tmobile-tmohs1.md for steps.");
Ok(())
}

View File

@@ -17,6 +17,7 @@ pub async fn telnet_send_command_with_output(
addr: SocketAddr,
command: &str,
wait_for_prompt: bool,
command_timeout: Duration,
) -> Result<String> {
if command.contains('\n') {
bail!("multi-line commands are not allowed");
@@ -41,11 +42,8 @@ pub async fn telnet_send_command_with_output(
writer.write_all(format!("echo RAYHUNTER_'TELNET'_COMMAND_START; {command}; echo RAYHUNTER_'TELNET'_COMMAND_DONE\r\n").as_bytes()).await?;
let mut read_buf = Vec::new();
timeout(Duration::from_secs(10), async {
loop {
let Ok(byte) = reader.read_u8().await else {
break;
};
timeout(command_timeout, async {
while let Ok(byte) = reader.read_u8().await {
read_buf.push(byte);
// when we see this string we know the command is done and can terminate.
@@ -60,7 +58,7 @@ pub async fn telnet_send_command_with_output(
}
})
.await
.context("command timed out after 10 seconds")?;
.with_context(|| format!("command timed out after {}s", command_timeout.as_secs()))?;
let string = String::from_utf8_lossy(&read_buf);
let start = string.rfind("RAYHUNTER_TELNET_COMMAND_START");
let end = string.rfind("RAYHUNTER_TELNET_COMMAND_DONE");
@@ -82,7 +80,9 @@ pub async fn telnet_send_command(
wait_for_prompt: bool,
) -> Result<()> {
let command = format!("{command}; echo command done, exit code $?");
let output = telnet_send_command_with_output(addr, &command, wait_for_prompt).await?;
let output =
telnet_send_command_with_output(addr, &command, wait_for_prompt, Duration::from_secs(10))
.await?;
if !output.contains(expected_output) {
bail!("{expected_output:?} not found in: {output}");
}
@@ -96,6 +96,9 @@ pub async fn telnet_send_file(
wait_for_prompt: bool,
) -> Result<()> {
print!("Sending file {filename} ... ");
// Allow 30s base + 2s per MB for the nc command to complete (covers slow WiFi links)
let transfer_timeout =
Duration::from_secs(30 + (payload.len() as u64 / (1024 * 1024)).max(1) * 2);
let nc_output = {
let filename = filename.to_owned();
let handle = tokio::spawn(async move {
@@ -103,6 +106,7 @@ pub async fn telnet_send_file(
addr,
&format!("nc -l -p 8081 2>&1 >{filename}.tmp"),
wait_for_prompt,
transfer_timeout,
)
.await
});

View File

@@ -26,6 +26,7 @@ pub async fn install(
Args {
admin_ip,
admin_password,
..
}: Args,
) -> Result<()> {
wingtech_run_install(admin_ip, admin_password).await
@@ -143,6 +144,12 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
reboot_device(addr, "shutdown -r -t 1 now", &admin_ip).await;
println!();
println!("Note: by default the CT2MHS01 shuts off Wi-Fi after ~10 minutes with no clients,");
println!("which blocks remote access to Rayhunter until you power cycle. To keep");
println!("Wi-Fi always on, open http://{admin_ip}/ -> Settings -> Sleep and set");
println!("Wi-Fi Standby to \"Always on\". See doc/wingtech-ct2mhs01.md for steps.");
Ok(())
}

View File

@@ -30,6 +30,6 @@ serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0"
num_enum = "0.7.4"
utoipa = { version = "5.4.0", optional = true }
async-compression = { version = "0.4.41", features = ["tokio", "gzip"] }
[dev-dependencies]
tempfile = "3"

View File

@@ -1,6 +1,5 @@
//! Diag protocol serialization/deserialization
use bytes::Bytes;
use chrono::{DateTime, FixedOffset};
use crc::{Algorithm, Crc};
use deku::prelude::*;
@@ -114,12 +113,6 @@ impl MessagesContainer {
}
}
impl From<MessagesContainer> for Bytes {
fn from(value: MessagesContainer) -> Self {
value.to_bytes().unwrap().into()
}
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
pub struct HdlcEncapsulatedMessage {
pub len: u32,

View File

@@ -40,4 +40,5 @@ pub enum Device {
Wingtech,
Pinephone,
Uz801,
Moxee,
}

View File

@@ -3,14 +3,8 @@
//! QmdlReader and QmdlWriter can read and write MessagesContainers to and from
//! QMDL files.
use std::io::{Cursor, ErrorKind};
use std::pin::Pin;
use std::task::Poll;
use crate::diag::{DataType, HdlcEncapsulatedMessage, MESSAGE_TERMINATOR, MessagesContainer};
use async_compression::tokio::bufread::GzipDecoder;
use async_compression::tokio::write::GzipEncoder;
use futures::TryStream;
use log::error;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
@@ -19,8 +13,8 @@ pub struct QmdlWriter<T>
where
T: AsyncWrite + Unpin,
{
writer: GzipEncoder<T>,
pub total_uncompressed_bytes: usize,
writer: T,
pub total_written: usize,
}
impl<T> QmdlWriter<T>
@@ -28,160 +22,50 @@ where
T: AsyncWrite + Unpin,
{
pub fn new(writer: T) -> Self {
let gzip_writer = GzipEncoder::new(writer);
QmdlWriter::new_with_existing_size(writer, 0)
}
pub fn new_with_existing_size(writer: T, existing_size: usize) -> Self {
QmdlWriter {
writer: gzip_writer,
total_uncompressed_bytes: 0,
writer,
total_written: existing_size,
}
}
pub async fn write_container(&mut self, container: &MessagesContainer) -> std::io::Result<()> {
for msg in &container.messages {
// for a gzipped file, we can't use `msg.data.len()` to
// determine the number of bytes written, so we have to
// manually do a `write_all()` type loop
let mut buf = Cursor::new(&msg.data);
loop {
let bytes_written = self.writer.write_buf(&mut buf).await?;
self.writer.flush().await?;
if bytes_written == 0 {
break;
}
self.total_uncompressed_bytes += bytes_written;
}
self.writer.write_all(&msg.data).await?;
self.total_written += msg.data.len();
}
Ok(())
}
pub async fn close(mut self) -> std::io::Result<()> {
self.writer.shutdown().await?;
Ok(())
}
}
#[derive(Debug)]
enum QmdlReaderSource<T> {
Compressed {
reader: GzipDecoder<BufReader<T>>,
eof: bool,
},
Uncompressed {
reader: T,
},
}
#[derive(Debug)]
struct QmdlAsyncReader<T> {
source: QmdlReaderSource<T>,
uncompressed_bytes_read: usize,
max_uncompressed_bytes: Option<usize>,
}
impl<T> QmdlAsyncReader<T>
where
T: AsyncRead,
{
pub fn new(reader: T, compressed: bool, max_uncompressed_bytes: Option<usize>) -> Self {
let source = if compressed {
QmdlReaderSource::Compressed {
reader: GzipDecoder::new(BufReader::new(reader)),
eof: false,
}
} else {
QmdlReaderSource::Uncompressed { reader }
};
Self {
source,
uncompressed_bytes_read: 0,
max_uncompressed_bytes,
}
}
}
impl<T> AsyncRead for QmdlAsyncReader<T>
where
T: AsyncRead + Unpin,
{
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
// if we've already read beyond the byte limit, return without reading
// into the buffer, essentially signalling EOF
if let Some(max_bytes) = self.max_uncompressed_bytes
&& self.uncompressed_bytes_read >= max_bytes
{
if self.uncompressed_bytes_read > max_bytes {
error!(
"warning: {} bytes read, but max_bytes was {}",
self.uncompressed_bytes_read, max_bytes
);
}
return Poll::Ready(Ok(()));
}
let before = buf.filled().len();
let this = self.get_mut();
let res = match &mut this.source {
QmdlReaderSource::Compressed { reader, eof } => {
// if we already determined we've reached the Gzip EOF, don't read more
if *eof {
return Poll::Ready(Ok(()));
}
match Pin::new(reader).poll_read(cx, buf) {
// if we hit an unexpected EOF in a Gzip file, it shouldn't
// be considered fatal, just a truncated file. mark that
// we're done and return the result as usual
Poll::Ready(Err(err)) if err.kind() == ErrorKind::UnexpectedEof => {
*eof = true;
Poll::Ready(Ok(()))
}
res => res,
}
}
QmdlReaderSource::Uncompressed { reader } => Pin::new(reader).poll_read(cx, buf),
};
// if we read more bytes than is allowed, cap the buffer by
// our max bytes
let after = buf.filled().len();
let read = after - before;
if let Some(max_bytes) = this.max_uncompressed_bytes
&& this.uncompressed_bytes_read + read > max_bytes
{
let overread = this.uncompressed_bytes_read + read - max_bytes;
buf.set_filled(after - overread);
}
res
}
}
#[derive(Debug)]
pub struct QmdlReader<T>
where
T: AsyncRead,
{
buf_reader: BufReader<QmdlAsyncReader<T>>,
reader: BufReader<T>,
bytes_read: usize,
max_bytes: Option<usize>,
}
impl<T> QmdlReader<T>
where
T: AsyncRead + Unpin,
{
pub fn new(reader: T, compressed: bool, max_uncompressed_bytes: Option<usize>) -> Self {
pub fn new(reader: T, max_bytes: Option<usize>) -> Self {
QmdlReader {
buf_reader: BufReader::new(QmdlAsyncReader::new(
reader,
compressed,
max_uncompressed_bytes,
)),
reader: BufReader::new(reader),
bytes_read: 0,
max_bytes,
}
}
pub fn as_stream(self) -> impl TryStream<Ok = MessagesContainer, Error = std::io::Error> {
futures::stream::try_unfold(self, |mut reader| async {
pub fn as_stream(
&mut self,
) -> impl TryStream<Ok = MessagesContainer, Error = std::io::Error> + '_ {
futures::stream::try_unfold(self, |reader| async {
let maybe_container = reader.get_next_messages_container().await?;
match maybe_container {
Some(container) => Ok(Some((container, reader))),
@@ -193,16 +77,22 @@ where
pub async fn get_next_messages_container(
&mut self,
) -> Result<Option<MessagesContainer>, std::io::Error> {
let mut buf = Vec::new();
if self
.buf_reader
.read_until(MESSAGE_TERMINATOR, &mut buf)
.await?
== 0
if let Some(max_bytes) = self.max_bytes
&& self.bytes_read >= max_bytes
{
if self.bytes_read > max_bytes {
error!(
"warning: {} bytes read, but max_bytes was {}",
self.bytes_read, max_bytes
);
}
return Ok(None);
}
let mut buf = Vec::new();
let bytes_read = self.reader.read_until(MESSAGE_TERMINATOR, &mut buf).await?;
self.bytes_read += bytes_read;
// Since QMDL is just a flat list of messages, we can't actually
// reproduce the container structure they came from in the original
// read. So we'll just pretend that all containers had exactly one
@@ -212,26 +102,13 @@ where
data_type: DataType::UserSpace,
num_messages: 1,
messages: vec![HdlcEncapsulatedMessage {
len: buf.len() as u32,
len: bytes_read as u32,
data: buf,
}],
}))
}
}
impl<T> AsyncRead for QmdlReader<T>
where
T: AsyncRead + Unpin,
{
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.get_mut().buf_reader).poll_read(cx, buf)
}
}
#[cfg(test)]
mod test {
use std::io::Cursor;
@@ -283,7 +160,7 @@ mod test {
#[tokio::test]
async fn test_unbounded_qmdl_reader() {
let mut buf = Cursor::new(get_test_message_bytes());
let mut reader = QmdlReader::new(&mut buf, false, None);
let mut reader = QmdlReader::new(&mut buf, None);
let expected_messages = get_test_messages();
for message in expected_messages {
let expected_container = MessagesContainer {
@@ -306,7 +183,7 @@ mod test {
let mut expected_messages = get_test_messages();
let limit = expected_messages[0].len + expected_messages[1].len;
let mut reader = QmdlReader::new(&mut buf, false, Some(limit as usize));
let mut reader = QmdlReader::new(&mut buf, Some(limit as usize));
for message in expected_messages.drain(0..2) {
let expected_container = MessagesContainer {
data_type: DataType::UserSpace,
@@ -324,22 +201,29 @@ mod test {
));
}
/// Writes the test containers to a QmdlWriter, optionally finishing the
/// gzip stream with a footer. Then, attempts to decompress the buffer with
/// a QmdlWriter, asserting that the containers match what's expected.
async fn run_compressed_reading_and_writing_tests(do_close: bool) {
let containers = get_test_containers();
#[tokio::test]
async fn test_qmdl_writer() {
let mut buf = Vec::new();
{
let mut writer = QmdlWriter::new(&mut buf);
for container in &containers {
writer.write_container(&container).await.unwrap();
}
if do_close {
writer.close().await.unwrap();
}
let mut writer = QmdlWriter::new(&mut buf);
let expected_containers = get_test_containers();
for container in &expected_containers {
writer.write_container(container).await.unwrap();
}
let mut reader = QmdlReader::new(Cursor::new(buf), true, None);
assert_eq!(writer.total_written, buf.len());
assert_eq!(buf, get_test_message_bytes());
}
#[tokio::test]
async fn test_writing_and_reading() {
let mut buf = Vec::new();
let mut writer = QmdlWriter::new(&mut buf);
let expected_containers = get_test_containers();
for container in &expected_containers {
writer.write_container(container).await.unwrap();
}
let limit = Some(buf.len());
let mut reader = QmdlReader::new(Cursor::new(&mut buf), limit);
let expected_messages = get_test_messages();
for message in expected_messages {
let expected_container = MessagesContainer {
@@ -357,10 +241,4 @@ mod test {
Ok(None)
));
}
#[tokio::test]
async fn test_compressed_reading_and_writing() {
run_compressed_reading_and_writing_tests(true).await;
run_compressed_reading_and_writing_tests(false).await;
}
}

View File

@@ -43,6 +43,25 @@ build_frontend() {
popd > /dev/null
}
build_wifi_tools() {
if [ -f "tools/build-wpa-supplicant/out/wpa_supplicant" ] \
&& [ -f "tools/build-wpa-supplicant/out/wpa_cli" ] \
&& [ -f "tools/build-wpa-supplicant/out/iw" ]; then
echo "WiFi tools already built, skipping."
return
fi
if ! command -v arm-linux-musleabihf-gcc &> /dev/null; then
echo "Error: arm-linux-musleabihf-gcc not found."
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.)"
exit 1
fi
echo "Building WiFi tools..."
./scripts/build-wpa-supplicant.sh
}
build_daemon() {
echo "Building daemon..."
cargo build-daemon-firmware-devel
@@ -57,6 +76,7 @@ case "$COMMAND" in
build)
check_dependencies
build_frontend
build_wifi_tools
build_daemon
echo ""
echo "Build complete! To install to a device, run:"

93
scripts/build-wpa-supplicant.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
# Cross-compile wpa_supplicant, wpa_cli, and iw for ARMv7 (musl static).
# Output: tools/build-wpa-supplicant/out/{wpa_supplicant,wpa_cli,iw}
#
# Requires: arm-linux-musleabihf-gcc (brew install FiloSottile/musl-cross/musl-cross)
set -e
WPA_VERSION="2.11"
WPA_URL="https://w1.fi/releases/wpa_supplicant-${WPA_VERSION}.tar.gz"
LIBNL_VERSION="3.11.0"
LIBNL_URL="https://github.com/thom311/libnl/releases/download/libnl${LIBNL_VERSION//\./_}/libnl-${LIBNL_VERSION}.tar.gz"
IW_VERSION="6.9"
IW_URL="https://www.kernel.org/pub/software/network/iw/iw-${IW_VERSION}.tar.xz"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUT_DIR="$SCRIPT_DIR/../tools/build-wpa-supplicant/out"
BUILD_DIR="/tmp/wpa-supplicant-build-$$"
CC="${CC:-arm-linux-musleabihf-gcc}"
STRIP="${STRIP:-arm-linux-musleabihf-strip}"
HOST="${HOST:-arm-linux-musleabihf}"
if ! command -v "$CC" >/dev/null 2>&1; then
echo "Error: $CC not found. Install with: brew install FiloSottile/musl-cross/musl-cross"
exit 1
fi
mkdir -p "$BUILD_DIR" "$OUT_DIR"
SYSROOT="$BUILD_DIR/sysroot"
mkdir -p "$SYSROOT"
echo "Building libnl ${LIBNL_VERSION}..."
curl -Lf "$LIBNL_URL" | tar xz -C "$BUILD_DIR"
cd "$BUILD_DIR/libnl-${LIBNL_VERSION}"
./configure \
--host="$HOST" \
CC="$CC" \
--prefix="$SYSROOT" \
--enable-static \
--disable-shared \
--disable-cli \
--disable-debug \
> /dev/null 2>&1
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" > /dev/null 2>&1
make install > /dev/null 2>&1
echo "Building wpa_supplicant ${WPA_VERSION}..."
cd "$BUILD_DIR"
curl -Lf "$WPA_URL" | tar xz
cd "wpa_supplicant-${WPA_VERSION}/wpa_supplicant"
cat > .config <<'WPACONF'
CONFIG_DRIVER_NL80211=y
CONFIG_LIBNL32=y
CONFIG_CRYPTO=internal
CONFIG_TLS=internal
CONFIG_INTERNAL_LIBTOMMATH=y
CONFIG_INTERNAL_LIBTOMMATH_FAST=y
CONFIG_CTRL_IFACE=y
CONFIG_BACKEND=file
CONFIG_NO_CONFIG_WRITE=y
CONFIG_NO_RANDOM_POOL=y
CONFIG_GETRANDOM=y
WPACONF
NL_CFLAGS="-I${SYSROOT}/include/libnl3"
NL_LIBS="-L${SYSROOT}/lib -lnl-genl-3 -lnl-3 -lpthread -lm"
make CC="$CC" \
EXTRA_CFLAGS="$NL_CFLAGS" \
LDFLAGS="-static" \
LIBS="$NL_LIBS" \
-j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
echo "Stripping..."
$STRIP wpa_supplicant wpa_cli
cp wpa_supplicant wpa_cli "$OUT_DIR/"
echo "Building iw ${IW_VERSION}..."
cd "$BUILD_DIR"
curl -Lf "$IW_URL" | tar xJ
cd "iw-${IW_VERSION}"
PKG_CONFIG_LIBDIR="$SYSROOT/lib/pkgconfig" \
make CC="$CC" \
LDFLAGS="-static" \
-j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
$STRIP iw
cp iw "$OUT_DIR/"
rm -rf "$BUILD_DIR"
echo "Done. Binaries in $OUT_DIR:"
ls -lh "$OUT_DIR"/{wpa_supplicant,wpa_cli,iw}

View File

@@ -1,5 +1,5 @@
asn1tools==0.166.0
bitstruct==8.19.0
asn1tools==0.167.0
bitstruct==8.22.1
diskcache==5.6.3
pycrate==0.7.8
pyparsing==3.1.2
pycrate==0.7.11
pyparsing==3.3.2