Compare commits

...

29 Commits

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

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

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

Compression comparison:

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

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

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

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

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

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

* Fix get_unuploaded_entries_with_age to use start_time

* Use better pattern when matching join! result

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

* Refactor Webdav with better string ownership

* Unformat Cargo.toml

* Add timeout config

* Use a single url config instead of host and path

* QMDL store returns single unuploaded entry at a time

* Reset Cargo.lock

---------

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

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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Another nicety of loading these files on runtime is that the installer
does not need to be recompiled when the daemon has been rebuilt. This
should make things like make.sh really obsolete, which bypass the
installer for speed.
2026-04-24 09:00:19 -07:00
58 changed files with 3011 additions and 3542 deletions
+16 -4
View File
@@ -1,3 +1,7 @@
# open-pull-requests-limit is used to disable automated version updates
# security updates are unaffected. see
# * https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/configuring-dependabot-version-updates#disabling-dependabot-version-updates
# * https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#open-pull-requests-limit-
version: 2 version: 2
updates: updates:
# Rust dependencies # Rust dependencies
@@ -5,8 +9,10 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
@@ -15,8 +21,10 @@ updates:
directory: "/tools" directory: "/tools"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
@@ -25,8 +33,10 @@ updates:
directory: "/daemon/web" directory: "/daemon/web"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
@@ -35,7 +45,9 @@ updates:
directory: "/installer-gui" directory: "/installer-gui"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 0
groups: groups:
dependency-type: security:
applies-to: "security-updates"
patterns: patterns:
- "*" - "*"
+19 -13
View File
@@ -28,6 +28,7 @@ jobs:
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }} daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }} web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }} docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }}
installer_build: ${{ steps.files_changed.outputs.installer_build != '0' }}
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }} installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }} installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }} rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
@@ -41,11 +42,13 @@ jobs:
run: | run: |
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }} lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, if workflow/cargo config files changed, or if # We rebuild everything if any of these conditions hold:
# the latest commit message contains "#build-all", run everything. # * We are on main
# Use #build-all in a commit message to force a full build on a PR # * Changes are made to github workflows
# branch (useful for testing release builds without merging to main). # * A cargo-workspace file changed (lockfile or .cargo), as that could affect any crate anywhere
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo || git log -1 --format='%s %b' | grep -qF '#build-all' # * Something from the script or dist folder changed (could be gated to installer, but some scripts like build_wpa_supplicant are part of the build process)
# * #build-all was used by the user to explicitly ask for this
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo -e '^Cargo\.lock$' -e '^Cargo\.toml$' -e ^dist/ -e ^scripts/ || git log -1 --format='%s %b' | grep -qF '#build-all'
then then
echo "building everything" echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT" echo code_count=forced >> "$GITHUB_OUTPUT"
@@ -141,13 +144,13 @@ jobs:
npm install npm install
npm run build npm run build
popd popd
NO_FIRMWARE_BIN=true cargo check --verbose cargo check --verbose
- name: Run tests - name: Run tests
run: | run: |
NO_FIRMWARE_BIN=true cargo test --verbose cargo test --verbose
- name: Run clippy - name: Run clippy
run: | run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose cargo clippy --verbose
installer_gui_check: installer_gui_check:
# we test the GUI installer separately to: # we test the GUI installer separately to:
@@ -173,9 +176,9 @@ jobs:
# fmt --all runs on all workspace packages so this is handled by # fmt --all runs on all workspace packages so this is handled by
# check_and_test above # check_and_test above
- name: Check - name: Check
run: NO_FIRMWARE_BIN=true cargo check --package installer-gui --verbose run: cargo check --package installer-gui --verbose
- name: Run clippy - name: Run clippy
run: NO_FIRMWARE_BIN=true cargo clippy --package installer-gui --verbose run: cargo clippy --package installer-gui --verbose
test_daemon_frontend: test_daemon_frontend:
needs: files_changed needs: files_changed
@@ -227,12 +230,12 @@ jobs:
shell: bash shell: bash
run: | run: |
cd installer cd installer
NO_FIRMWARE_BIN=true cargo check --verbose cargo check --verbose
- name: cargo test - name: cargo test
shell: bash shell: bash
run: | run: |
cd installer cd installer
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features cargo test --verbose --no-default-features
build_rayhunter_check: build_rayhunter_check:
if: needs.files_changed.outputs.daemon_changed == 'true' if: needs.files_changed.outputs.daemon_changed == 'true'
@@ -305,7 +308,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
build_wpa_supplicant: build_wpa_supplicant:
if: needs.files_changed.outputs.installer_changed == 'true' if: needs.files_changed.outputs.installer_build == 'true'
needs: needs:
- files_changed - files_changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -423,6 +426,7 @@ jobs:
needs: needs:
- build_rayhunter - build_rayhunter
- build_rootshell - build_rootshell
- build_wpa_supplicant
- files_changed - files_changed
- installer_gui_check - installer_gui_check
- test_installer_frontend - test_installer_frontend
@@ -479,6 +483,7 @@ jobs:
needs: needs:
- build_rayhunter - build_rayhunter
- build_rootshell - build_rootshell
- build_wpa_supplicant
- files_changed - files_changed
- installer_gui_check - installer_gui_check
- test_installer_frontend - test_installer_frontend
@@ -522,6 +527,7 @@ jobs:
needs: needs:
- build_rayhunter - build_rayhunter
- build_rootshell - build_rootshell
- build_wpa_supplicant
- files_changed - files_changed
- installer_gui_check - installer_gui_check
- test_installer_frontend - test_installer_frontend
+1 -1
View File
@@ -76,7 +76,7 @@ This one is for maintainers of Rayhunter.
1. Make a PR changing the versions in `Cargo.toml` and other files. 1. Make a PR changing the versions in `Cargo.toml` and other files.
This could be automated better but right now it's manual. You can do this easily with sed: This could be automated better but right now it's manual. You can do this easily with sed:
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml` `sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml installer-gui/src-tauri/Cargo.toml`
2. Merge PR and make a tag. 2. Merge PR and make a tag.
Generated
+483 -175
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter-check" name = "rayhunter-check"
version = "0.10.2" version = "0.11.1"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.10.2" version = "0.11.1"
edition = "2024" edition = "2024"
rust-version = "1.88.0" rust-version = "1.88.0"
@@ -21,7 +21,7 @@ apidocs = ["dep:utoipa", "wifi-station/utoipa"]
[dependencies] [dependencies]
rayhunter = { path = "../lib" } rayhunter = { path = "../lib" }
wifi-station = { git = "https://github.com/BeigeBox/wifi-station", rev = "e8ec5b4" } wifi-station = "0.10.1"
toml = "0.8.8" toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] } serde = { version = "1.0.193", features = ["derive"] }
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] } tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
@@ -40,7 +40,7 @@ image = { version = "0.25.1", default-features = false, features = ["png", "gif
tempfile = "3.10.2" tempfile = "3.10.2"
async_zip = { version = "0.0.17", features = ["tokio"] } async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98" anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false } reqwest = { version = "0.12.20", default-features = false, features = ["stream"] }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
rustls-post-quantum = { version = "0.2.4", optional = true } rustls-post-quantum = { version = "0.2.4", optional = true }
async-trait = "0.1.88" async-trait = "0.1.88"
+38 -6
View File
@@ -46,10 +46,43 @@ pub struct Config {
pub wifi_enabled: bool, pub wifi_enabled: bool,
/// Vector containing wifi client DNS servers /// Vector containing wifi client DNS servers
pub dns_servers: Option<Vec<String>>, pub dns_servers: Option<Vec<String>>,
/// Wifi client firewall mode /// WebDAV upload configuration. The upload worker runs whenever `webdav.url` is non-empty.
pub firewall_restrict_outbound: bool, pub webdav: WebdavConfig,
/// Vector containing additional wifi client firewall ports to open }
pub firewall_allowed_ports: Option<Vec<u16>>,
/// Configuration for uploading finished QMDL recordings to a WebDAV server.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct WebdavConfig {
/// WebDAV server base URL, e.g. "https://example.com/remote.php/files/untitaker/my-subfolder/"
pub url: String,
/// Optional username for HTTP Basic auth
pub username: Option<String>,
/// Optional password for HTTP Basic auth
pub password: Option<String>,
/// Timeout (in seconds) for each upload request
pub upload_timeout_secs: u64,
/// How often (in seconds) the worker scans for entries to upload
pub poll_interval_secs: u64,
/// Minimum age (in seconds) an entry must have before it becomes eligible for upload
pub min_age_secs: i64,
/// Delete the file locally after a successful upload
pub delete_on_upload: bool,
}
impl Default for WebdavConfig {
fn default() -> Self {
WebdavConfig {
url: String::new(),
username: None,
password: None,
upload_timeout_secs: 300,
poll_interval_secs: 3600,
min_age_secs: 86400,
delete_on_upload: false,
}
}
} }
impl Default for Config { impl Default for Config {
@@ -72,8 +105,7 @@ impl Default for Config {
wifi_security: None, wifi_security: None,
wifi_enabled: false, wifi_enabled: false,
dns_servers: None, dns_servers: None,
firewall_restrict_outbound: true, webdav: WebdavConfig::default(),
firewall_allowed_ports: None,
} }
} }
} }
+16 -2
View File
@@ -10,6 +10,7 @@ use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt, future}; use futures::{StreamExt, TryStreamExt, future};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rayhunter::Device;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::mpsc::{Receiver, Sender};
@@ -370,7 +371,7 @@ impl DiagTask {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread( pub fn run_diag_read_thread(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
mut dev: DiagDevice, device: Device,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>, mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>, qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>, ui_update_sender: Sender<display::DisplayState>,
@@ -382,8 +383,21 @@ pub fn run_diag_read_thread(
min_space_to_continue_mb: u64, min_space_to_continue_mb: u64,
) { ) {
task_tracker.spawn(async move { task_tracker.spawn(async move {
info!("Using configuration for device: {0:?}", device);
let mut dev = DiagDevice::new(&device)
.await?;
dev.config_logs()
.await?;
let mut diag_stream = pin!(dev.as_stream().into_stream()); let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel, min_space_to_start_mb, min_space_to_continue_mb); let mut diag_task = DiagTask::new(
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
min_space_to_start_mb,
min_space_to_continue_mb
);
qmdl_file_tx qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None }) .send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await .await
-3
View File
@@ -1,4 +1,3 @@
use rayhunter::diag_device::DiagDeviceError;
use thiserror::Error; use thiserror::Error;
use crate::qmdl_store::RecordingStoreError; use crate::qmdl_store::RecordingStoreError;
@@ -7,8 +6,6 @@ use crate::qmdl_store::RecordingStoreError;
pub enum RayhunterError { pub enum RayhunterError {
#[error("Config file parsing error: {0}")] #[error("Config file parsing error: {0}")]
ConfigFileParsingError(#[from] toml::de::Error), ConfigFileParsingError(#[from] toml::de::Error),
#[error("Diag intialization error: {0}")]
DiagInitError(DiagDeviceError),
#[error("Tokio error: {0}")] #[error("Tokio error: {0}")]
TokioError(#[from] tokio::io::Error), TokioError(#[from] tokio::io::Error),
#[error("QmdlStore error: {0}")] #[error("QmdlStore error: {0}")]
-92
View File
@@ -1,92 +0,0 @@
use anyhow::{Result, bail};
use log::{info, warn};
use tokio::process::Command;
use wifi_station::detect_bridge_iface;
use crate::config::Config;
async fn run_iptables(args: &[&str]) -> Result<()> {
let out = Command::new("iptables").args(args).output().await?;
if !out.status.success() {
bail!(
"iptables {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
pub async fn apply(config: &Config) {
let _ = Command::new("iptables")
.args(["-F", "OUTPUT"])
.output()
.await;
if config.firewall_restrict_outbound {
// Fail open on partial setup error: reachability beats restriction when recovery means physical access.
match setup_outbound_whitelist(&config.firewall_allowed_ports, &config.ntfy_url).await {
Ok(()) => info!("outbound firewall active: allowing DHCP, DNS, HTTPS only"),
Err(e) => warn!("firewall setup failed: {e} (fail-open, outbound unrestricted)"),
}
}
}
async fn setup_outbound_whitelist(
extra_ports: &Option<Vec<u16>>,
ntfy_url: &Option<String>,
) -> Result<()> {
run_iptables(&["-A", "OUTPUT", "-o", "lo", "-j", "ACCEPT"]).await?;
run_iptables(&["-A", "OUTPUT", "-o", detect_bridge_iface(), "-j", "ACCEPT"]).await?;
run_iptables(&[
"-A",
"OUTPUT",
"-m",
"state",
"--state",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
])
.await?;
run_iptables(&[
"-A", "OUTPUT", "-p", "udp", "--dport", "67:68", "-j", "ACCEPT",
])
.await?;
run_iptables(&["-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "ACCEPT"]).await?;
run_iptables(&["-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-j", "ACCEPT"]).await?;
run_iptables(&[
"-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "ACCEPT",
])
.await?;
if let Some(url) = ntfy_url
&& let Ok(parsed) = url::Url::parse(url)
&& let Some(port) = parsed.port_or_known_default()
&& port != 443
{
let port_str = port.to_string();
run_iptables(&[
"-A", "OUTPUT", "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
])
.await?;
info!("firewall: auto-allowed port {port} for ntfy");
}
if let Some(ports) = extra_ports {
for port in ports {
let port_str = port.to_string();
run_iptables(&[
"-A", "OUTPUT", "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
])
.await?;
}
}
run_iptables(&["-A", "OUTPUT", "-j", "DROP"]).await?;
let _ = tokio::fs::write("/proc/sys/net/bridge/bridge-nf-call-iptables", "0").await;
Ok(())
}
+1 -1
View File
@@ -5,13 +5,13 @@ pub mod crypto_provider;
pub mod diag; pub mod diag;
pub mod display; pub mod display;
pub mod error; pub mod error;
pub mod firewall;
pub mod key_input; pub mod key_input;
pub mod notifications; pub mod notifications;
pub mod pcap; pub mod pcap;
pub mod qmdl_store; pub mod qmdl_store;
pub mod server; pub mod server;
pub mod stats; pub mod stats;
pub mod webdav;
#[cfg(feature = "apidocs")] #[cfg(feature = "apidocs")]
use utoipa::OpenApi; use utoipa::OpenApi;
+13 -12
View File
@@ -5,13 +5,14 @@ mod crypto_provider;
mod diag; mod diag;
mod display; mod display;
mod error; mod error;
mod firewall;
mod key_input; mod key_input;
mod notifications; mod notifications;
mod pcap; mod pcap;
mod qmdl_store; mod qmdl_store;
mod server; mod server;
mod stats; mod stats;
mod webdav;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
@@ -27,6 +28,7 @@ use crate::server::{
scan_wifi, serve_static, set_config, set_time_offset, test_notification, scan_wifi, serve_static, set_config, set_time_offset, test_notification,
}; };
use crate::stats::{get_qmdl_manifest, get_system_stats}; use crate::stats::{get_qmdl_manifest, get_system_stats};
use crate::webdav::run_webdav_upload_worker;
use wifi_station::WifiStatus; use wifi_station::WifiStatus;
use analysis::{ use analysis::{
@@ -42,7 +44,6 @@ use diag::{
use log::{error, info}; use log::{error, info};
use qmdl_store::RecordingStoreError; use qmdl_store::RecordingStoreError;
use rayhunter::Device; use rayhunter::Device;
use rayhunter::diag_device::DiagDevice;
use stats::get_log; use stats::get_log;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::select; use tokio::select;
@@ -214,18 +215,10 @@ async fn run_with_config(
let notification_service = NotificationService::new(config.ntfy_url.clone()); let notification_service = NotificationService::new(config.ntfy_url.clone());
if !config.debug_mode { if !config.debug_mode {
info!("Using configuration for device: {0:?}", config.device);
let mut dev = DiagDevice::new(&config.device)
.await
.map_err(RayhunterError::DiagInitError)?;
dev.config_logs()
.await
.map_err(RayhunterError::DiagInitError)?;
info!("Starting Diag Thread"); info!("Starting Diag Thread");
run_diag_read_thread( run_diag_read_thread(
&task_tracker, &task_tracker,
dev, config.device.clone(),
diag_rx, diag_rx,
diag_tx.clone(), diag_tx.clone(),
ui_update_tx.clone(), ui_update_tx.clone(),
@@ -294,7 +287,15 @@ async fn run_with_config(
shutdown_token.clone(), shutdown_token.clone(),
wifi_status.clone(), wifi_status.clone(),
); );
firewall::apply(&config).await;
if !config.webdav.url.trim().is_empty() {
run_webdav_upload_worker(
&task_tracker,
shutdown_token.clone(),
qmdl_store_lock.clone(),
config.webdav.clone().into(),
);
}
let state = Arc::new(ServerState { let state = Arc::new(ServerState {
config_path: args.config_path.clone(), config_path: args.config_path.clone(),
+113 -1
View File
@@ -2,7 +2,7 @@ use std::io::{self, ErrorKind};
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{DateTime, Local}; use chrono::{DateTime, Local, TimeDelta};
use log::{info, warn}; use log::{info, warn};
use rayhunter::util::RuntimeMetadata; use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -67,6 +67,9 @@ pub struct ManifestEntry {
pub arch: Option<String>, pub arch: Option<String>,
#[serde(default)] #[serde(default)]
pub stop_reason: Option<String>, pub stop_reason: Option<String>,
/// When the manifest was uploaded to a WebDAV server
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub upload_time: Option<DateTime<Local>>,
} }
impl ManifestEntry { impl ManifestEntry {
@@ -82,6 +85,7 @@ impl ManifestEntry {
system_os: Some(metadata.system_os), system_os: Some(metadata.system_os),
arch: Some(metadata.arch), arch: Some(metadata.arch),
stop_reason: None, stop_reason: None,
upload_time: None,
} }
} }
@@ -212,6 +216,7 @@ impl RecordingStore {
system_os: None, system_os: None,
arch: None, arch: None,
stop_reason: None, stop_reason: None,
upload_time: None,
}); });
} }
@@ -342,6 +347,23 @@ impl RecordingStore {
Ok(()) Ok(())
} }
pub fn get_next_unuploaded_entry(&self, min_age: TimeDelta) -> Option<String> {
let now = rayhunter::clock::get_adjusted_now();
self.manifest
.entries
.iter()
.filter_map(|entry| {
if self.is_current_entry(&entry.name) || entry.upload_time.is_some() {
return None;
}
let age = now - entry.last_message_time.unwrap_or(entry.start_time);
(age > min_age).then_some((&entry.name, age))
})
.max_by_key(|(_, age)| *age)
.map(|(name, _)| name.clone())
}
// Finds an entry by filename // Finds an entry by filename
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> { pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
let entry_index = self let entry_index = self
@@ -368,6 +390,22 @@ impl RecordingStore {
Ok(()) Ok(())
} }
pub async fn mark_entry_as_uploaded(
&mut self,
name: &str,
upload_time: DateTime<Local>,
) -> Result<(), RecordingStoreError> {
let entry_index = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?;
self.manifest.entries[entry_index].upload_time = Some(upload_time);
self.write_manifest().await?;
Ok(())
}
pub fn is_current_entry(&self, name: &str) -> bool { pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry { match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) { Some(idx) => match self.manifest.entries.get(idx) {
@@ -544,4 +582,78 @@ mod tests {
store.delete_all_entries().await.unwrap(); store.delete_all_entries().await.unwrap();
assert!(store.current_entry.is_none()); assert!(store.current_entry.is_none());
} }
#[tokio::test]
async fn test_mark_entry_as_uploaded_sets_time_and_persists() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let name = store.manifest.entries[0].name.clone();
store.close_current_entry().await.unwrap();
let upload_time = Local::now();
store
.mark_entry_as_uploaded(&name, upload_time)
.await
.unwrap();
assert_eq!(store.manifest.entries[0].upload_time, Some(upload_time));
let reloaded = RecordingStore::load(dir.path()).await.unwrap();
assert_eq!(reloaded.manifest.entries[0].upload_time, Some(upload_time));
}
#[tokio::test]
async fn test_mark_entry_as_uploaded_missing_entry() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
assert!(matches!(
store.mark_entry_as_uploaded("nope", Local::now()).await,
Err(RecordingStoreError::NoSuchEntryError)
));
}
#[tokio::test]
async fn test_get_next_unuploaded_entry() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
for _ in 0..3 {
let _ = store.new_entry().await.unwrap();
}
store.manifest.entries[0].name = "entry-0".to_owned();
store.manifest.entries[0].start_time = Local::now() - TimeDelta::seconds(10);
store.manifest.entries[0].last_message_time = None;
store.manifest.entries[1].name = "entry-1".to_owned();
store.manifest.entries[1].start_time = Local::now() - TimeDelta::seconds(10);
store.manifest.entries[1].last_message_time = Some(Local::now() - TimeDelta::seconds(5));
store.manifest.entries[2].name = "entry-2".to_owned();
store.manifest.entries[2].start_time = Local::now() - TimeDelta::seconds(10);
store.manifest.entries[2].last_message_time = Some(Local::now() - TimeDelta::seconds(1));
assert_eq!(
store.get_next_unuploaded_entry(TimeDelta::seconds(3600)),
None,
);
assert_eq!(
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
Some("entry-0".to_owned())
);
store
.mark_entry_as_uploaded("entry-0", Local::now())
.await
.unwrap();
assert_eq!(
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
Some("entry-1".to_owned())
);
store
.mark_entry_as_uploaded("entry-1", Local::now())
.await
.unwrap();
assert_eq!(store.get_next_unuploaded_entry(TimeDelta::seconds(3)), None);
}
} }
+446
View File
@@ -0,0 +1,446 @@
use std::fmt::Display;
use std::{sync::Arc, time::Duration};
use chrono::TimeDelta;
use log::{info, warn};
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
use reqwest::{Body, Client, Response};
use tokio::fs::File;
use tokio::join;
use tokio::{select, sync::RwLock, time};
use tokio_util::io::ReaderStream;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::config::WebdavConfig;
use crate::qmdl_store::RecordingStore;
pub struct WebdavUploadWorkerConfig {
poll_interval: Duration,
min_age: TimeDelta,
url: String,
username: Option<String>,
password: Option<String>,
timeout: Duration,
delete_on_upload: bool,
}
impl From<WebdavConfig> for WebdavUploadWorkerConfig {
fn from(value: WebdavConfig) -> Self {
WebdavUploadWorkerConfig {
poll_interval: Duration::from_secs(value.poll_interval_secs),
min_age: TimeDelta::seconds(value.min_age_secs),
url: value.url,
username: value.username,
password: value.password,
timeout: Duration::from_secs(value.upload_timeout_secs),
delete_on_upload: value.delete_on_upload,
}
}
}
enum FileKind {
Analysis,
Qmdl,
}
impl FileKind {
fn as_extension(&self) -> &'static str {
match self {
FileKind::Analysis => ".ndjson",
FileKind::Qmdl => ".qmdl",
}
}
}
impl Display for FileKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileKind::Analysis => write!(f, "analysis"),
FileKind::Qmdl => write!(f, "QMDL"),
}
}
}
#[derive(Debug, Clone)]
struct WebDavClient {
client: Client,
url: String,
username: Option<String>,
password: Option<String>,
}
impl WebDavClient {
fn new(
mut url: String,
username: Option<String>,
password: Option<String>,
timeout: Duration,
) -> Result<Self, reqwest::Error> {
if !url.ends_with('/') {
url.push('/');
}
Ok(Self {
client: reqwest::Client::builder().timeout(timeout).build()?,
url,
username,
password,
})
}
async fn try_upload_file(&self, file: File, name: &str) -> anyhow::Result<Response> {
let file_size = file.metadata().await?.len();
let stream = ReaderStream::new(file);
let body = Body::wrap_stream(stream);
let target = format!("{}{}", self.url, name);
let client = self
.client
.put(&target)
.header(CONTENT_TYPE, "application/octet-stream")
.header(CONTENT_LENGTH, file_size);
let client = match (&self.username, &self.password) {
(Some(username), Some(password)) => client.basic_auth(username, Some(password)),
(Some(username), None) => client.basic_auth(username, None::<&str>),
(None, None) => client,
(None, Some(_)) => {
warn!(
"Got WebDAV auth setting with no username but with a password, skipping authentication"
);
client
}
};
let resp = client.body(body).send().await?.error_for_status();
Ok(resp?)
}
}
async fn try_upload_entry(
client: WebDavClient,
store: Arc<RwLock<RecordingStore>>,
entry_name: String,
file_kind: FileKind,
shutdown_token: CancellationToken,
) -> Option<()> {
let read_lock = store.read().await;
let entry_idx = read_lock.entry_for_name(&entry_name)?.0;
let file = match file_kind {
FileKind::Analysis => read_lock.open_entry_analysis(entry_idx).await,
FileKind::Qmdl => read_lock.open_entry_qmdl(entry_idx).await,
};
drop(read_lock);
let Ok(file) = file.map_err(|err| {
warn!(
"Unable to open entry: {} {} file: {:?}",
entry_name, file_kind, err
)
}) else {
return None;
};
let file_name = format!("{}{}", entry_name, file_kind.as_extension());
let res = select! {
_ = shutdown_token.cancelled() => {
warn!(
"Cancelling upload for entry {} {} file: received shutdown signal",
entry_name, file_kind
);
return None;
},
res = client.try_upload_file(file, &file_name) => res,
};
match res {
Ok(_) => {
info!("Uploaded {} file for entry {}", file_kind, entry_name);
Some(())
}
Err(err) => {
warn!(
"Failed to upload {} file for entry {}: {:?}",
file_kind, entry_name, err
);
None
}
}
}
pub fn run_webdav_upload_worker(
task_tracker: &TaskTracker,
shutdown_token: CancellationToken,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
config: WebdavUploadWorkerConfig,
) {
task_tracker.spawn(async move {
let mut interval = time::interval(config.poll_interval);
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
let webdav_client = match WebDavClient::new(
config.url,
config.username,
config.password,
config.timeout,
) {
Ok(client) => client,
Err(err) => {
warn!("Unable to create WebDAV client: {:?}", err);
return;
}
};
loop {
select! {
_ = shutdown_token.cancelled() => break,
_ = interval.tick() => {
loop {
let Some(unuploaded_entry) = qmdl_store_lock
.read()
.await
.get_next_unuploaded_entry(config.min_age) else {
break;
};
let (Some(()), Some(())) = join!(
try_upload_entry(
webdav_client.clone(),
qmdl_store_lock.clone(),
unuploaded_entry.clone(),
FileKind::Qmdl,
shutdown_token.clone(),
),
try_upload_entry(
webdav_client.clone(),
qmdl_store_lock.clone(),
unuploaded_entry.clone(),
FileKind::Analysis,
shutdown_token.clone()
),
) else {
break;
};
if config.delete_on_upload {
match qmdl_store_lock.write().await.delete_entry(&unuploaded_entry).await {
Ok(_) => info!("Successfully deleted entry: {} after upload to WebDAV", unuploaded_entry),
Err(err) => warn!("Unable to delete entry: {} after upload to WebDAV: {}", unuploaded_entry, err),
}
} else {
match qmdl_store_lock.write().await.mark_entry_as_uploaded(&unuploaded_entry, rayhunter::clock::get_adjusted_now()).await {
Ok(_) => info!("Successfully marked entry: {} as uploaded", unuploaded_entry),
Err(err) => warn!("Unable to mark entry: {} as uploaded: {}", unuploaded_entry, err),
}
}
}
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
Router,
body::Bytes,
extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode},
routing::put,
};
use tempfile::Builder;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[derive(Clone, Debug)]
struct RecordedPut {
path: String,
auth: Option<String>,
body: Vec<u8>,
}
async fn capture_put(
State(state): State<Arc<Mutex<Vec<RecordedPut>>>>,
AxumPath(path): AxumPath<String>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(String::from);
state.lock().await.push(RecordedPut {
path,
auth,
body: body.to_vec(),
});
StatusCode::CREATED
}
async fn setup_webdav_server() -> (Arc<Mutex<Vec<RecordedPut>>>, String) {
crate::crypto_provider::install_default();
let state = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/{*path}", put(capture_put))
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}/dav", addr);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
(state, url)
}
async fn cleanup_worker(shutdown: CancellationToken, tracker: TaskTracker) {
shutdown.cancel();
tracker.close();
tracker.wait().await;
}
async fn make_store_with_closed_entry(
dir: &std::path::Path,
) -> (Arc<RwLock<RecordingStore>>, String) {
let mut store = RecordingStore::create(dir).await.unwrap();
let (mut qmdl_file, mut analysis_file) = store.new_entry().await.unwrap();
qmdl_file.write_all(b"fake qmdl payload").await.unwrap();
qmdl_file.flush().await.unwrap();
analysis_file
.write_all(b"fake ndjson payload")
.await
.unwrap();
analysis_file.flush().await.unwrap();
let entry_index = store.current_entry.unwrap();
let name = store.manifest.entries[entry_index].name.clone();
store.update_entry_qmdl_size(entry_index, 17).await.unwrap();
store.close_current_entry().await.unwrap();
(Arc::new(RwLock::new(store)), name)
}
#[tokio::test]
async fn test_webdav_upload_worker_uploads_entry() {
let (captured, url) = setup_webdav_server().await;
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let config = WebdavUploadWorkerConfig {
poll_interval: Duration::from_millis(50),
min_age: TimeDelta::seconds(-1),
url,
username: Some("user".to_string()),
password: Some("password".to_string()),
timeout: Duration::from_secs(1),
delete_on_upload: false,
};
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(shutdown, tracker).await;
let recorded = captured.lock().await;
assert_eq!(recorded.len(), 2);
let paths: Vec<&str> = recorded.iter().map(|r| r.path.as_str()).collect();
let qmdl_path = format!("dav/{}.qmdl", entry_name);
let ndjson_path = format!("dav/{}.ndjson", entry_name);
assert!(paths.contains(&qmdl_path.as_str()));
assert!(paths.contains(&ndjson_path.as_str()));
for put in recorded.iter() {
assert_eq!(put.auth.as_deref(), Some("Basic dXNlcjpwYXNzd29yZA=="));
}
let qmdl_body = recorded
.iter()
.find(|r| r.path == qmdl_path)
.unwrap()
.body
.clone();
let ndjson_body = recorded
.iter()
.find(|r| r.path == ndjson_path)
.unwrap()
.body
.clone();
drop(recorded);
assert_eq!(qmdl_body, b"fake qmdl payload");
assert_eq!(ndjson_body, b"fake ndjson payload");
let store_read = store.read().await;
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
assert!(entry.upload_time.is_some());
}
#[tokio::test]
async fn test_webdav_upload_worker_deletes_when_configured() {
let (captured, url) = setup_webdav_server().await;
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let config = WebdavUploadWorkerConfig {
poll_interval: Duration::from_millis(50),
min_age: TimeDelta::seconds(-1),
url,
username: None,
password: None,
timeout: Duration::from_secs(1),
delete_on_upload: true,
};
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(shutdown, tracker).await;
assert_eq!(captured.lock().await.len(), 2);
let store_read = store.read().await;
assert!(store_read.entry_for_name(&entry_name).is_none());
}
#[tokio::test]
async fn test_webdav_upload_worker_respects_min_age() {
let (captured, url) = setup_webdav_server().await;
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let config = WebdavUploadWorkerConfig {
poll_interval: Duration::from_millis(50),
min_age: TimeDelta::seconds(3600),
url,
username: None,
password: None,
timeout: Duration::from_secs(1),
delete_on_upload: false,
};
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(shutdown, tracker).await;
assert!(captured.lock().await.is_empty());
let store_read = store.read().await;
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
assert!(entry.upload_time.is_none());
}
}
+6 -1
View File
@@ -22,7 +22,7 @@ export default ts.config(
}, },
}, },
{ {
files: ['**/*.svelte'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
@@ -48,6 +48,11 @@ export default ts.config(
format: ['snake_case'], format: ['snake_case'],
}, },
], ],
// these rules should eventually be enabled, just disabled them to
// make dependency upgrades easier.
'svelte/prefer-svelte-reactivity': 'off',
'svelte/require-each-key': 'off',
'svelte/no-navigation-without-resolve': 'off',
}, },
} }
); );
+1357 -2891
View File
File diff suppressed because it is too large Load Diff
+19 -18
View File
@@ -15,25 +15,26 @@
"fix": "eslint --fix ." "fix": "eslint --fix ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.5", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.53.4", "@sveltejs/kit": "^2.58.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"@types/node": "^24.7.0", "@types/node": "^25.6.0",
"autoprefixer": "^10.4.20", "eslint": "^10.2.1",
"eslint": "^9.7.0", "eslint-config-prettier": "^10.1.8",
"eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^3.17.1",
"eslint-plugin-svelte": "^2.36.0", "globals": "^17.5.0",
"globals": "^15.0.0", "prettier": "^3.8.3",
"prettier": "^3.3.2", "prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-svelte": "^3.2.6", "svelte": "^5.55.5",
"svelte": "^5.53.7", "svelte-check": "^4.4.6",
"svelte-check": "^4.0.0", "tailwindcss": "^4.2.2",
"tailwindcss": "^3.4.9", "typescript": "^6.0.3",
"typescript": "^5.0.0", "typescript-eslint": "^8.59.0",
"typescript-eslint": "^8.0.0", "vite": "^8.0.10",
"vite": "^7.3.2", "vitest": "^4.1.5"
"vitest": "^3.2.4"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+16 -3
View File
@@ -1,3 +1,16 @@
@import 'tailwindcss/base'; @import 'tailwindcss';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @theme {
--color-rayhunter-blue: #4e4eb1;
--color-rayhunter-dark-blue: #3f3da0;
--color-rayhunter-green: #94ea18;
}
/* v4 dropped the v3 preflight rule that set `cursor: pointer` on buttons.
* Restore it so enabled buttons get the pointer cursor. */
@layer base {
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}
@@ -20,7 +20,7 @@
{#if action_errors.length > 0} {#if action_errors.length > 0}
<div <div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3" border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
> >
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
@@ -13,6 +13,11 @@
manager: AnalysisManager; manager: AnalysisManager;
current: boolean; current: boolean;
} = $props(); } = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: 'long',
dateStyle: 'short',
});
</script> </script>
<div class="container mt-2"> <div class="container mt-2">
@@ -49,20 +54,30 @@
{:else} {:else}
<p>No warnings to display!</p> <p>No warnings to display!</p>
{/if} {/if}
{#if metadata !== undefined && metadata.rayhunter !== undefined} <div>
<div> <p class="text-lg underline">Metadata</p>
<p class="text-lg underline">Metadata</p> {#if metadata !== undefined && metadata.rayhunter !== undefined}
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p> <p><b>Rayhunter version:</b> {metadata.rayhunter.rayhunter_version}</p>
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p> <p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
</div> {:else}
<p>N/A (analysis generated by an older version of rayhunter)</p>
{/if}
{#if entry.upload_time}
<p>
<b>WebDAV uploaded at:</b>
<span class="text-green-700"
>{date_formatter.format(entry.upload_time)}</span
>
</p>
{/if}
</div>
{#if metadata && metadata.analyzers}
<div> <div>
<p class="text-lg underline">Analyzers</p> <p class="text-lg underline">Enabled Analyzers</p>
{#each metadata.analyzers as analyzer} {#each metadata.analyzers as analyzer}
<p><b>{analyzer.name}:</b> {analyzer.description}</p> <p><b>{analyzer.name}:</b> {analyzer.description}</p>
{/each} {/each}
</div> </div>
{:else}
<p>N/A (analysis generated by an older version of rayhunter)</p>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -55,7 +55,7 @@
{#if show_alert} {#if show_alert}
<div <div
class="bg-yellow-100 border-yellow-400 drop-shadow p-4 flex flex-col gap-2 border rounded-md" class="bg-yellow-100 border-yellow-400 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md"
> >
<span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700"> <span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
<svg <svg
+196 -83
View File
@@ -26,12 +26,15 @@
let scanning = $state(false); let scanning = $state(false);
let scanResults = $state<WifiNetwork[]>([]); let scanResults = $state<WifiNetwork[]>([]);
let dnsServersInput = $state(''); let dnsServersInput = $state('');
let webdavExpanded = $state(false);
let webdavUrlInput = $state<HTMLInputElement | null>(null);
async function load_config() { async function load_config() {
try { try {
loading = true; loading = true;
config = await get_config(); config = await get_config();
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : ''; dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
webdavExpanded = config.webdav.url.trim() !== '';
message = ''; message = '';
messageType = null; messageType = null;
poll_wifi_status(); poll_wifi_status();
@@ -164,7 +167,7 @@
<select <select
id="ui_level" id="ui_level"
bind:value={config.ui_level} bind:value={config.ui_level}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
> >
<option value={0}>0 - Invisible mode</option> <option value={0}>0 - Invisible mode</option>
<option value={1}>1 - Subtle mode (colored line)</option> <option value={1}>1 - Subtle mode (colored line)</option>
@@ -188,7 +191,7 @@
<select <select
id="key_input_mode" id="key_input_mode"
bind:value={config.key_input_mode} bind:value={config.key_input_mode}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
> >
<option value={0}>0 - Disable button control</option> <option value={0}>0 - Disable button control</option>
<option value={1}>1 - Double-tap power button to start new recording</option <option value={1}>1 - Double-tap power button to start new recording</option
@@ -202,7 +205,7 @@
id="colorblind_mode" id="colorblind_mode"
type="checkbox" type="checkbox"
bind:checked={config.colorblind_mode} bind:checked={config.colorblind_mode}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700"> <label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
Colorblind Mode Colorblind Mode
@@ -210,7 +213,7 @@
</div> </div>
</div> </div>
<div class="border-t pt-4 mt-6 space-y-3"> <div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
<div> <div>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1"> <label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
@@ -221,7 +224,7 @@
id="ntfy_url" id="ntfy_url"
type="url" type="url"
bind:value={config.ntfy_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"> <p class="text-xs text-gray-500 mt-1">
Test button below uses the saved configuration URL, not the input above Test button below uses the saved configuration URL, not the input above
@@ -259,7 +262,7 @@
</button> </button>
{#if testMessage} {#if testMessage}
<div <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-red-100 text-red-700'
: 'bg-green-100 text-green-700'}" : 'bg-green-100 text-green-700'}"
> >
@@ -303,7 +306,7 @@
</div> </div>
</div> </div>
<div class="border-t pt-4 mt-6 space-y-3"> <div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
<div> <div>
@@ -318,7 +321,7 @@
type="number" type="number"
min="1" min="1"
bind:value={config.min_space_to_start_recording_mb} bind:value={config.min_space_to_start_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/> />
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Recording will not start if less than this amount of disk space is free Recording will not start if less than this amount of disk space is free
@@ -337,7 +340,7 @@
type="number" type="number"
min="1" min="1"
bind:value={config.min_space_to_continue_recording_mb} bind:value={config.min_space_to_continue_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/> />
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Recording will stop automatically if disk space drops below this level Recording will stop automatically if disk space drops below this level
@@ -345,8 +348,175 @@
</div> </div>
</div> </div>
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">WebDAV Upload</h3>
<p class="text-xs text-gray-500">
Once a recording has been closed for at least the configured age, both the
.qmdl and .ndjson files are uploaded in the background to the WebDAV server.
</p>
<div class="flex items-center">
<input
id="webdav_enabled"
type="checkbox"
checked={webdavExpanded}
onchange={(e) => {
webdavExpanded = e.currentTarget.checked;
if (webdavExpanded) {
setTimeout(() => webdavUrlInput?.focus(), 0);
} else {
if (config) config.webdav.url = '';
}
}}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label for="webdav_enabled" class="ml-2 block text-sm text-gray-700">
Enable WebDAV upload
</label>
</div>
{#if webdavExpanded}
<div>
<label
for="webdav_url"
class="block text-sm font-medium text-gray-700 mb-1"
>
Server URL
</label>
<input
id="webdav_url"
type="url"
bind:this={webdavUrlInput}
bind:value={config.webdav.url}
onblur={() => {
if (config && config.webdav.url.trim() === '') {
webdavExpanded = false;
}
}}
placeholder="https://dav.example.com/rayhunter/"
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">
Files are uploaded via HTTP PUT under this base URL. No folders are
created, and folders in this base URL are assumed to exist already.
</p>
</div>
<div>
<label
for="webdav_username"
class="block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<input
id="webdav_username"
type="text"
bind:value={config.webdav.username}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Optional. Leave blank for unauthenticated uploads.
</p>
</div>
<div>
<label
for="webdav_password"
class="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="webdav_password"
type="password"
bind:value={config.webdav.password}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
A password without a username will be rejected and the request will
be sent unauthenticated.
</p>
</div>
<div>
<label
for="webdav_upload_timeout_secs"
class="block text-sm font-medium text-gray-700 mb-1"
>
Upload Timeout (seconds)
</label>
<input
id="webdav_upload_timeout_secs"
type="number"
min="1"
bind:value={config.webdav.upload_timeout_secs}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
<div>
<label
for="webdav_poll_interval_secs"
class="block text-sm font-medium text-gray-700 mb-1"
>
Poll Interval (seconds)
</label>
<input
id="webdav_poll_interval_secs"
type="number"
min="1"
bind:value={config.webdav.poll_interval_secs}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
How often the worker checks for new entries to upload.
</p>
</div>
<div>
<label
for="webdav_min_age_secs"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Age Before Upload (seconds)
</label>
<input
id="webdav_min_age_secs"
type="number"
min="0"
bind:value={config.webdav.min_age_secs}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
How long a recording must be closed before it becomes eligible for
upload.
</p>
</div>
<div class="flex items-center">
<input
id="webdav_delete_on_upload"
type="checkbox"
bind:checked={config.webdav.delete_on_upload}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/>
<label
for="webdav_delete_on_upload"
class="ml-2 block text-sm text-gray-700"
>
Delete on successful upload
</label>
</div>
<p class="text-xs text-gray-500">
When enabled, the local files are removed after a successful upload.
Otherwise the manifest is just marked as uploaded.
</p>
{/if}
</div>
{#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'} {#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
<div class="border-t pt-4 mt-6 space-y-3"> <div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Connect the device to an existing WiFi network for internet access (e.g. Connect the device to an existing WiFi network for internet access (e.g.
@@ -359,7 +529,7 @@
id="wifi_enabled" id="wifi_enabled"
type="checkbox" type="checkbox"
bind:checked={config.wifi_enabled} bind:checked={config.wifi_enabled}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label for="wifi_enabled" class="ml-2 block text-sm text-gray-700"> <label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
Enable WiFi Enable WiFi
@@ -402,7 +572,7 @@
type="text" type="text"
bind:value={config.wifi_ssid} bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork" placeholder="MyWiFiNetwork"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/> />
<button <button
type="button" type="button"
@@ -421,7 +591,7 @@
{#if scanResults.length > 0} {#if scanResults.length > 0}
<div <div
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y" class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y divide-gray-200"
> >
{#each scanResults as network} {#each scanResults as network}
<button <button
@@ -449,7 +619,7 @@
<select <select
id="wifi_security" id="wifi_security"
bind:value={config.wifi_security} bind:value={config.wifi_security}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
> >
<option value="wpa_psk">WPA2 (WPA-PSK)</option> <option value="wpa_psk">WPA2 (WPA-PSK)</option>
<option value="sae">WPA3 (SAE)</option> <option value="sae">WPA3 (SAE)</option>
@@ -469,7 +639,7 @@
type="password" type="password"
bind:value={config.wifi_password} bind:value={config.wifi_password}
placeholder="Enter password" placeholder="Enter password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/> />
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Changing the network requires re-entering the password. Changing the network requires re-entering the password.
@@ -489,7 +659,7 @@
type="text" type="text"
bind:value={dnsServersInput} bind:value={dnsServersInput}
placeholder="9.9.9.9, 149.112.112.112" placeholder="9.9.9.9, 149.112.112.112"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/> />
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9, Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9,
@@ -500,64 +670,7 @@
</div> </div>
{/if} {/if}
<div class="border-t pt-4 mt-6 space-y-3"> <div class="border-t border-gray-200 pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Device Security</h3>
<div class="flex items-center">
<input
id="firewall_restrict_outbound"
type="checkbox"
bind:checked={config.firewall_restrict_outbound}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="firewall_restrict_outbound"
class="ml-2 block text-sm text-gray-700"
>
Restrict outbound traffic
</label>
</div>
<p class="text-xs text-gray-500">
Only allows DNS, DHCP, and HTTPS (port 443) outbound. Blocks all other
outbound connections on every interface (WiFi and cellular). Loopback and
hotspot traffic are always allowed. Changes take effect immediately.
</p>
{#if config.firewall_restrict_outbound}
<div>
<label
for="firewall_allowed_ports"
class="block text-sm font-medium text-gray-700 mb-1"
>
Additional Allowed Ports
</label>
<input
id="firewall_allowed_ports"
type="text"
value={config.firewall_allowed_ports
? config.firewall_allowed_ports.join(', ')
: ''}
oninput={(e) => {
const val = (e.target as HTMLInputElement).value.trim();
config!.firewall_allowed_ports =
val.length > 0
? val
.split(',')
.map((s) => parseInt(s.trim()))
.filter((n) => !isNaN(n) && n >= 1 && n <= 65535)
: null;
}}
placeholder="22, 80"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Comma-separated TCP ports, e.g. 22, 80
</p>
</div>
{/if}
</div>
<div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4"> <h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings Analyzer Heuristic Settings
</h3> </h3>
@@ -567,7 +680,7 @@
id="imsi_requested" id="imsi_requested"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.imsi_requested} bind:checked={config.analyzers.imsi_requested}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700"> <label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
IMSI Requested Heuristic IMSI Requested Heuristic
@@ -579,7 +692,7 @@
id="connection_redirect_2g_downgrade" id="connection_redirect_2g_downgrade"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.connection_redirect_2g_downgrade} bind:checked={config.analyzers.connection_redirect_2g_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label <label
for="connection_redirect_2g_downgrade" for="connection_redirect_2g_downgrade"
@@ -594,7 +707,7 @@
id="lte_sib6_and_7_downgrade" id="lte_sib6_and_7_downgrade"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.lte_sib6_and_7_downgrade} bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label <label
for="lte_sib6_and_7_downgrade" for="lte_sib6_and_7_downgrade"
@@ -609,7 +722,7 @@
id="null_cipher" id="null_cipher"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.null_cipher} bind:checked={config.analyzers.null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label for="null_cipher" class="ml-2 block text-sm text-gray-700"> <label for="null_cipher" class="ml-2 block text-sm text-gray-700">
Null Cipher Heuristic Null Cipher Heuristic
@@ -621,7 +734,7 @@
id="nas_null_cipher" id="nas_null_cipher"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.nas_null_cipher} bind:checked={config.analyzers.nas_null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700"> <label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
NAS Null Cipher Heuristic NAS Null Cipher Heuristic
@@ -633,7 +746,7 @@
id="incomplete_sib" id="incomplete_sib"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.incomplete_sib} bind:checked={config.analyzers.incomplete_sib}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700"> <label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic Incomplete SIB Heuristic
@@ -645,7 +758,7 @@
id="test_analyzer" id="test_analyzer"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.test_analyzer} bind:checked={config.analyzers.test_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700"> <label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisy!) Test Heuristic (noisy!)
@@ -656,7 +769,7 @@
id="diagnostic_analyzer" id="diagnostic_analyzer"
type="checkbox" type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer} bind:checked={config.analyzers.diagnostic_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
/> />
<label <label
for="diagnostic_analyzer" for="diagnostic_analyzer"
@@ -700,7 +813,7 @@
</form> </form>
{#if message} {#if message}
<div <div
class="mt-4 p-3 rounded {messageType === 'error' class="mt-4 p-3 rounded-sm {messageType === 'error'
? 'bg-red-100 text-red-700' ? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}" : 'bg-green-100 text-green-700'}"
> >
@@ -5,8 +5,8 @@
<div class="flex flex-row justify-end gap-2"> <div class="flex flex-row justify-end gap-2">
<DeleteButton <DeleteButton
text="Delete ALL Recordings" text="Delete ALL Recordings"
prompt={`Are you sure you want to delete ALL recordings?`} prompt="Are you sure you want to delete ALL recordings?"
url={`/api/delete-all-recordings`} url="/api/delete-all-recordings"
name="all recodings" name="all recodings"
/> />
</div> </div>
@@ -44,7 +44,7 @@
</script> </script>
<div <div
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden" class="{status_row_color} {status_border_color} drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
> >
{#if current} {#if current}
<div class="flex flex-row justify-between gap-2"> <div class="flex flex-row justify-between gap-2">
@@ -82,7 +82,7 @@
> >
</div> </div>
{#if entry.stop_reason} {#if entry.stop_reason}
<div class="bg-yellow-50 border border-yellow-300 rounded p-2 text-yellow-800 text-sm"> <div class="bg-yellow-50 border border-yellow-300 rounded-sm p-2 text-yellow-800 text-sm">
{entry.stop_reason} {entry.stop_reason}
</div> </div>
{/if} {/if}
@@ -100,7 +100,7 @@
/> />
{/if} {/if}
</div> </div>
<div class="border-b {analysis_visible ? '' : 'hidden'}"> <div class="border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} {manager} {current} /> <AnalysisView {entry} {manager} {current} />
</div> </div>
</div> </div>
@@ -16,7 +16,7 @@
{#if $screenIsLgUp} {#if $screenIsLgUp}
<table class="table-auto text-left table"> <table class="table-auto text-left table">
<thead> <thead>
<tr class="bg-gray-100 drop-shadow"> <tr class="bg-gray-100 drop-shadow-sm">
<th class="p-2" scope="col">ID</th> <th class="p-2" scope="col">ID</th>
<th class="p-2" scope="col">Started</th> <th class="p-2" scope="col">Started</th>
<th class="p-2" scope="col">Last Message</th> <th class="p-2" scope="col">Last Message</th>
@@ -36,7 +36,7 @@
} }
</script> </script>
<tr class="{status_row_color} drop-shadow"> <tr class="{status_row_color} drop-shadow-sm">
<td class="p-2">{entry.name}</td> <td class="p-2">{entry.name}</td>
<td class="p-2">{date_formatter.format(entry.start_time)}</td> <td class="p-2">{date_formatter.format(entry.start_time)}</td>
<td class="p-2" <td class="p-2"
@@ -65,8 +65,8 @@
</td> </td>
{/if} {/if}
</tr> </tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}"> <tr class="{alternating_row_color} border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="9"> <td class="border-t border-gray-200 border-dashed p-2" colspan="9">
<AnalysisView {entry} {manager} {current} /> <AnalysisView {entry} {manager} {current} />
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -35,7 +35,7 @@
{#if shown} {#if shown}
<div <div
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
flex flex-col p-2 drop-shadow" flex flex-col p-2 drop-shadow-sm"
> >
<div class="flex justify-between items-center p-1"> <div class="flex justify-between items-center p-1">
<span class="text-2xl">{title}</span> <span class="text-2xl">{title}</span>
@@ -6,7 +6,7 @@
stats: SystemStats; stats: SystemStats;
} = $props(); } = $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 battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
let bar_color = $derived.by(() => { let bar_color = $derived.by(() => {
@@ -36,29 +36,29 @@
</script> </script>
<div <div
class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100" class="flex-1 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
> >
<p class="text-xl mb-2">System Information</p> <p class="text-xl mb-2">System Information</p>
<table class="table-auto border"> <table class="table-auto border border-gray-200">
<tbody> <tbody>
<tr class="border"> <tr class="border border-gray-200">
<th class={table_cell_classes}> Rayhunter Version </th> <th class={table_cell_classes}> Rayhunter Version </th>
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td> <td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
</tr> </tr>
<tr class="border"> <tr class="border border-gray-200">
<th class={table_cell_classes}> Storage </th> <th class={table_cell_classes}> Storage </th>
<td class={table_cell_classes}> <td class={table_cell_classes}>
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats {stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
.disk_stats.available_size} available) .disk_stats.available_size} available)
</td> </td>
</tr> </tr>
<tr class="border-b"> <tr class="border-b border-gray-200">
<th class={table_cell_classes}> Memory (RAM) </th> <th class={table_cell_classes}> Memory (RAM) </th>
<td class={table_cell_classes}> <td class={table_cell_classes}>
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used} Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td> </td>
</tr> </tr>
<tr class="border-b"> <tr class="border-b border-gray-200">
<th class={table_cell_classes}> Battery </th> <th class={table_cell_classes}> Battery </th>
<td class={table_cell_classes}> <td class={table_cell_classes}>
<svg <svg
+5
View File
@@ -12,6 +12,7 @@ interface JsonManifestEntry {
last_message_time: string; last_message_time: string;
qmdl_size_bytes: number; qmdl_size_bytes: number;
stop_reason: string | null; stop_reason: string | null;
upload_time: string | null;
} }
export class Manifest { export class Manifest {
@@ -59,6 +60,7 @@ export class ManifestEntry {
public analysis_status: AnalysisStatus | undefined = $state(undefined); public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined); public analysis_report: AnalysisReport | string | undefined = $state(undefined);
public stop_reason: string | undefined = $state(undefined); public stop_reason: string | undefined = $state(undefined);
public upload_time: Date | undefined = $state(undefined);
constructor(json: JsonManifestEntry) { constructor(json: JsonManifestEntry) {
this.name = json.name; this.name = json.name;
@@ -70,6 +72,9 @@ export class ManifestEntry {
if (json.stop_reason) { if (json.stop_reason) {
this.stop_reason = json.stop_reason; this.stop_reason = json.stop_reason;
} }
if (json.upload_time) {
this.upload_time = new Date(json.upload_time);
}
} }
get_readable_qmdl_size(): string { get_readable_qmdl_size(): string {
+3 -1
View File
@@ -19,7 +19,9 @@ export function parse_ndjson(input: string): NewlineDeliminatedJson {
// however, if we've reached the end of the input, that means we // however, if we've reached the end of the input, that means we
// were given invalid nd-json // were given invalid nd-json
if (lines.length === 0) { if (lines.length === 0) {
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`); throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`, {
cause: e,
});
} }
} }
} }
+11
View File
@@ -18,6 +18,16 @@ export enum enabled_notifications {
LowBattery = 'LowBattery', LowBattery = 'LowBattery',
} }
export interface WebdavConfig {
url: string;
username: string | null;
password: string | null;
upload_timeout_secs: number;
poll_interval_secs: number;
min_age_secs: number;
delete_on_upload: boolean;
}
export interface Config { export interface Config {
device: string; device: string;
ui_level: number; ui_level: number;
@@ -35,6 +45,7 @@ export interface Config {
dns_servers: string[] | null; dns_servers: string[] | null;
firewall_restrict_outbound: boolean; firewall_restrict_outbound: boolean;
firewall_allowed_ports: number[] | null; firewall_allowed_ports: number[] | null;
webdav: WebdavConfig;
} }
export interface WifiStatus { export interface WifiStatus {
+6 -4
View File
@@ -57,7 +57,9 @@
<LogView bind:shown={logview_shown} /> <LogView bind:shown={logview_shown} />
<ConfigForm bind:shown={config_shown} /> <ConfigForm bind:shown={config_shown} />
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center"> <div
class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow-sm flex flex-row justify-between items-center"
>
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ --> <!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" /> <img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
@@ -204,7 +206,7 @@
<div class="m-4 xl:mx-8 flex flex-col gap-4"> <div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if update_error !== undefined} {#if update_error !== undefined}
<div <div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between" class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
> >
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"> <span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
<svg <svg
@@ -249,7 +251,7 @@
/> />
{:else} {:else}
<div <div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between" class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
> >
<span <span
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600" class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
@@ -295,7 +297,7 @@
type="checkbox" type="checkbox"
id="filter_threshold" id="filter_threshold"
bind:checked={filter_threshold} bind:checked={filter_threshold}
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" class="px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
/> />
</div> </div>
</div> </div>
-19
View File
@@ -1,19 +0,0 @@
import type { Config } from 'tailwindcss';
import { breakpoints } from './src/theme';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
'rayhunter-blue': '#4e4eb1',
'rayhunter-dark-blue': '#3f3da0',
'rayhunter-green': '#94ea18',
},
screens: breakpoints,
},
},
plugins: [],
} as Config;
+2 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
server: { server: {
@@ -26,7 +27,7 @@ export default defineConfig({
}, },
}, },
}, },
plugins: [sveltekit()], plugins: [tailwindcss(), sveltekit()],
build: { build: {
// Force everything into one HTML file. SvelteKit will still generate // Force everything into one HTML file. SvelteKit will still generate
// a lot of JS files but they are deadweight and will not be included // a lot of JS files but they are deadweight and will not be included
+23 -11
View File
@@ -43,17 +43,29 @@ wifi_enabled = false
# Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified. # Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified.
# dns_servers = ["9.9.9.9", "149.112.112.112"] # dns_servers = ["9.9.9.9", "149.112.112.112"]
# Device Security # WebDAV Upload
# Restrict outbound traffic to essential services only (DHCP, DNS, # If a [webdav] section is present, finished recordings (both the raw .qmdl file
# HTTPS, and replies to inbound connections). Applies to all outbound # and its .ndjson analysis output) are uploaded in the background to a WebDAV
# interfaces (WiFi and cellular). Loopback and hotspot bridge traffic # server once they've been closed for at least min_age_secs. After a successful
# are always allowed. Defaults to true (recommended). # upload the entry is either marked as uploaded in the manifest, or deleted
firewall_restrict_outbound = true # locally if delete_on_upload = true. With no [webdav] section, no upload
# worker runs.
# Additional TCP ports to allow outbound when the firewall is active. #
# DHCP (67-68), DNS (53), and HTTPS (443) are always allowed. # [webdav]
# Example: allow HTTP (80) and SSH (22). # url = "https://dav.example.com/rayhunter"
# firewall_allowed_ports = [80, 22] # # HTTP Basic auth. Both fields are optional; a password without a username is
# # rejected and the request is sent unauthenticated.
# username = "user"
# password = "pass"
# # Timeout in seconds for each upload request (default 300).
# upload_timeout_secs = 300
# # How often the worker scans for eligible entries (default 3600).
# poll_interval_secs = 3600
# # Minimum age in seconds before an entry becomes eligible for upload
# # (default 86400 = 1 day).
# min_age_secs = 86400
# # Delete the entry locally after a successful upload (default false).
# delete_on_upload = false
# Analyzer Configuration # Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics # Enable/disable specific IMSI catcher detection heuristics
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
CONFIG="/data/rayhunter/config.toml"
case "$1" in
start)
if grep -q '^firewall_restrict_outbound = true' "$CONFIG" 2>/dev/null; then
iptables -F OUTPUT
iptables -A OUTPUT -o lo -j ACCEPT
for br in bridge0 br0; do
[ -d "/sys/class/net/$br" ] && iptables -A OUTPUT -o "$br" -j ACCEPT
done
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
iptables -A OUTPUT -j DROP
echo 0 > /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null
fi
;;
stop)
iptables -F OUTPUT
iptables -P OUTPUT ACCEPT
;;
esac
+33 -2
View File
@@ -51,8 +51,39 @@ You can also configure WiFi during installation:
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass' ./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
``` ```
## Device Security ## WebDAV Upload
- **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. Rayhunter can automatically upload finished recordings to a WebDAV server. When a `[webdav]` section is present in `config.toml`, a background worker periodically scans the recording store and uploads any closed entry that is older than `min_age_secs`. Each eligible entry uploads two files: the raw `.qmdl` capture and its `.ndjson` analysis output. After a successful upload the entry is either marked as uploaded in the manifest (and skipped on subsequent polls), or deleted locally if `delete_on_upload = true`. With no `[webdav]` section, no upload worker runs.
WebDAV upload is currently configurable only by editing `config.toml` — there is no web UI control for it yet.
| Key | Required | Default | Description |
| --- | --- | --- | --- |
| `url` | yes | — | WebDAV server base URL, e.g. `https://example.com/remote.php/files/user/rayhunter/` |
| `username` | no | — | HTTP Basic auth username |
| `password` | no | — | HTTP Basic auth password |
| `upload_timeout_secs` | no | `300` | Timeout (seconds) for each upload request |
| `poll_interval_secs` | no | `3600` | How often (seconds) the worker scans for eligible entries |
| `min_age_secs` | no | `86400` | Minimum age (seconds) an entry must have before it becomes eligible for upload |
| `delete_on_upload` | no | `false` | Delete the entry locally after a successful upload |
Example:
```toml
[webdav]
url = "https://dav.example.com/rayhunter/"
username = "user"
password = "pass"
upload_timeout_secs = 300
poll_interval_secs = 3600
min_age_secs = 86400
delete_on_upload = false
```
A few notes on behavior:
- **Auth:** HTTP Basic. Supplying a `password` without a `username` is rejected — the request is sent unauthenticated and a warning is logged.
- **Retries and overwrites:** each entry's two files (`.qmdl` and `.ndjson`) must both upload successfully before the entry is marked as uploaded in the manifest. If one upload fails, the entry stays unmarked and both files are retried on the next poll — the one that previously succeeded will be overwritten on the server. Once an entry is marked as uploaded, Rayhunter will not upload it again.
- **Currently-recording entry:** the active recording is never uploaded; only closed entries are eligible.
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in). 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).
-3
View File
@@ -44,9 +44,6 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip' # Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
# may be required if different from the default. # may be required if different from the default.
# Optionally configure WiFi client mode during install (Orbic and Moxee only):
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
# Or install over USB if you want ADB and a root shell (not recommended for most users) # Or install over USB if you want ADB and a root shell (not recommended for most users)
./installer orbic-usb ./installer orbic-usb
+2 -1
View File
@@ -18,7 +18,8 @@ The TP-Link M7350 supports many more frequency bands than Orbic and therefore wo
The TP-Link comes in many different *hardware versions*. Support for installation varies: The TP-Link comes in many different *hardware versions*. Support for installation varies:
* `1.0`, `2.0`: **Not supported**, devs are not able to obtain a device * `1.0`, Confirmed working. Successfully tested by a user with the Windows installer (rayhunter-v0.10.2-windows-x86_64). Ensure the SD card is formatted as FAT32 before installation.
* `2.0`: **Not supported**, devs are not able to obtain a device
* `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: **Tested, no known issues since 0.3.0.** * `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: **Tested, no known issues since 0.3.0.**
* `6.2`: **One user reported it is working, not tested** * `6.2`: **One user reported it is working, not tested**
* `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332)) * `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332))
+69 -54
View File
@@ -975,9 +975,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.10.1", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -985,9 +985,9 @@
} }
}, },
"node_modules/@tauri-apps/cli": { "node_modules/@tauri-apps/cli": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==",
"dev": true, "dev": true,
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"bin": { "bin": {
@@ -1001,23 +1001,23 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-arm64": "2.11.1",
"@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.11.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.11.1",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.11.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.11.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.11.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.11.1",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1" "@tauri-apps/cli-win32-x64-msvc": "2.11.1"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-arm64": { "node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1032,9 +1032,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-darwin-x64": { "node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1049,9 +1049,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1066,13 +1066,16 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-gnu": { "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1083,13 +1086,16 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-musl": { "node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1100,13 +1106,16 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": { "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1117,13 +1126,16 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-gnu": { "node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1134,13 +1146,16 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-musl": { "node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1151,9 +1166,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-arm64-msvc": { "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1168,9 +1183,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-ia32-msvc": { "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1185,9 +1200,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": { "node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1202,12 +1217,12 @@
} }
}, },
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.11.0"
} }
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "installer-gui" name = "installer-gui"
version = "0.10.2" version = "0.11.1"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "installer" name = "installer"
version = "0.10.2" version = "0.11.1"
edition = "2024" edition = "2024"
[lib] [lib]
+11 -18
View File
@@ -1,8 +1,6 @@
use std::path::Path; use std::path::Path;
use std::process::exit;
fn main() { fn main() {
println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN");
println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE"); println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE");
let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| { let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| {
// Default to firmware-devel for debug builds, firmware for release builds // Default to firmware-devel for debug builds, firmware for release builds
@@ -26,24 +24,19 @@ fn main() {
fn set_binary_var(include_dir: &Path, var: &str, file: &str) { fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
println!("cargo::rerun-if-env-changed={var}"); println!("cargo::rerun-if-env-changed={var}");
if std::env::var_os("NO_FIRMWARE_BIN").is_some() { if std::env::var_os(var).is_some() {
let out_dir = std::env::var("OUT_DIR").unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
let blank = Path::new(&out_dir).join("blank");
std::fs::write(&blank, []).unwrap();
println!("cargo::rustc-env={var}={}", blank.display());
return; return;
} }
if std::env::var_os(var).is_none() { let binary = include_dir.join(file);
let binary = include_dir.join(file); println!("cargo::rerun-if-changed={}", binary.display());
println!("cargo::rerun-if-changed={}", binary.display()); if binary.exists() {
if !binary.exists() {
println!(
"cargo::error=Firmware binary {file} not present at {}",
binary.display()
);
exit(0);
}
println!("cargo::rustc-env={var}={}", binary.display()); println!("cargo::rustc-env={var}={}", binary.display());
} else {
println!(
"cargo::warning=Firmware binary {file} not present at {}; \
installers that need it will fail",
binary.display()
);
println!("cargo::rustc-env={var}=");
} }
} }
+16 -9
View File
@@ -47,20 +47,27 @@ pub async fn install_config<C: DeviceConnection>(
/// ///
/// Skips any binary that is already present on the device (e.g. provided by firmware), /// Skips any binary that is already present on the device (e.g. provided by firmware),
/// since those may be newer or better-integrated than the bundled versions. /// since those may be newer or better-integrated than the bundled versions.
pub async fn install_wifi_tools<C: DeviceConnection>( ///
conn: &mut C, /// In debug builds the wpa-supplicant binaries may not be bundled (build.rs sets the
wpa_supplicant: &[u8], /// env vars to empty in that case); when so, this is a no-op so devs don't have to
wpa_cli: &[u8], /// build wpa-supplicant just to install on Orbic.
iw: &[u8], pub async fn install_wifi_tools<C: DeviceConnection>(conn: &mut C) -> Result<()> {
) -> Result<()> { if env!("FILE_WPA_SUPPLICANT").is_empty() {
println!("wifi tools were not built into this installer, skipping");
return Ok(());
}
let tools: &[(&str, &str, &[u8])] = &[ let tools: &[(&str, &str, &[u8])] = &[
( (
"wpa_supplicant", "wpa_supplicant",
"/data/rayhunter/bin/wpa_supplicant", "/data/rayhunter/bin/wpa_supplicant",
wpa_supplicant, crate::get_file!("FILE_WPA_SUPPLICANT"),
), ),
("wpa_cli", "/data/rayhunter/bin/wpa_cli", wpa_cli), (
("iw", "/data/rayhunter/bin/iw", iw), "wpa_cli",
"/data/rayhunter/bin/wpa_cli",
crate::get_file!("FILE_WPA_CLI"),
),
("iw", "/data/rayhunter/bin/iw", crate::get_file!("FILE_IW")),
]; ];
for &(name, dest, payload) in tools { for &(name, dest, payload) in tools {
if device_has_binary(conn, name).await { if device_has_binary(conn, name).await {
+23
View File
@@ -0,0 +1,23 @@
#[cfg(debug_assertions)]
macro_rules! get_file {
($var:literal) => {{
let path = env!($var);
match ::std::fs::read(path) {
Ok(bytes) => bytes.leak() as &'static [u8],
Err(e) => panic!("Failed to read file for {}: {}", $var, e),
}
}};
}
#[cfg(not(debug_assertions))]
macro_rules! get_file {
($var:literal) => {{
const _: () = assert!(
!env!($var).is_empty(),
concat!($var, " was not bundled at build time"),
);
include_bytes!(env!($var)) as &'static [u8]
}};
}
pub(crate) use get_file;
+3
View File
@@ -6,6 +6,9 @@ use env_logger::Env;
use anyhow::bail; use anyhow::bail;
mod connection; mod connection;
mod files;
pub(crate) use files::*;
mod moxee; mod moxee;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
mod orbic; mod orbic;
+3 -16
View File
@@ -136,7 +136,7 @@ async fn force_debug_mode() -> Result<ADBUSBDevice> {
} }
async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> { async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
let rootshell_bin = include_bytes!(env!("FILE_ROOTSHELL")); let rootshell_bin = crate::get_file!("FILE_ROOTSHELL");
install_file(adb_device, "/bin/rootshell", rootshell_bin).await?; install_file(adb_device, "/bin/rootshell", rootshell_bin).await?;
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
@@ -151,7 +151,7 @@ async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
} }
async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Result<ADBUSBDevice> { async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Result<ADBUSBDevice> {
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
adb_at_syscmd( adb_at_syscmd(
&mut adb_device, &mut adb_device,
@@ -170,13 +170,7 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
device: &mut adb_device, device: &mut adb_device,
}; };
install_config(&mut conn, "orbic", reset_config).await?; install_config(&mut conn, "orbic", reset_config).await?;
install_wifi_tools( install_wifi_tools(&mut conn).await?;
&mut conn,
include_bytes!(env!("FILE_WPA_SUPPLICANT")),
include_bytes!(env!("FILE_WPA_CLI")),
include_bytes!(env!("FILE_IW")),
)
.await?;
} }
install_file( install_file(
@@ -191,15 +185,8 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
include_bytes!("../../dist/scripts/misc-daemon"), include_bytes!("../../dist/scripts/misc-daemon"),
) )
.await?; .await?;
install_file(
&mut adb_device,
"/etc/init.d/S01iptables",
include_bytes!("../../dist/scripts/S01iptables"),
)
.await?;
adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/rayhunter_daemon").await?; adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/rayhunter_daemon").await?;
adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/misc-daemon").await?; adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/misc-daemon").await?;
adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/S01iptables").await?;
println!("done"); println!("done");
print!("Waiting for reboot... "); print!("Waiting for reboot... ");
adb_at_syscmd(&mut adb_device, "shutdown -r -t 1 now").await?; adb_at_syscmd(&mut adb_device, "shutdown -r -t 1 now").await?;
+2 -22
View File
@@ -216,7 +216,7 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> { async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> {
let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?; let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
// Remount filesystem as read-write to allow modifications // Remount filesystem as read-write to allow modifications
// This is really only necessary for the Moxee Hotspot // This is really only necessary for the Moxee Hotspot
@@ -248,13 +248,7 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
) )
.await?; .await?;
install_wifi_tools( install_wifi_tools(&mut conn).await?;
&mut conn,
include_bytes!(env!("FILE_WPA_SUPPLICANT")),
include_bytes!(env!("FILE_WPA_CLI")),
include_bytes!(env!("FILE_IW")),
)
.await?;
install_config(&mut conn, "orbic", reset_config).await?; install_config(&mut conn, "orbic", reset_config).await?;
@@ -273,13 +267,6 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
false, false,
) )
.await?; .await?;
telnet_send_file(
addr,
"/etc/init.d/S01iptables",
include_bytes!("../../dist/scripts/S01iptables"),
false,
)
.await?;
telnet_send_command( telnet_send_command(
addr, addr,
@@ -302,13 +289,6 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
false, false,
) )
.await?; .await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/S01iptables",
"exit code 0",
false,
)
.await?;
println!("Installation complete. Rebooting device..."); println!("Installation complete. Rebooting device...");
telnet_send_command(addr, "shutdown -r -t 1 now", "", false) telnet_send_command(addr, "shutdown -r -t 1 now", "", false)
+1 -1
View File
@@ -29,7 +29,7 @@ pub async fn install() -> Result<()> {
run_command_expect(&mut adb, "mount -o remount,rw /", "exit code 0").await?; run_command_expect(&mut adb, "mount -o remount,rw /", "exit code 0").await?;
run_command_expect(&mut adb, "mkdir -p /data/rayhunter", "exit code 0").await?; run_command_expect(&mut adb, "mkdir -p /data/rayhunter", "exit code 0").await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
adb.write_file("/data/rayhunter/rayhunter-daemon", rayhunter_daemon_bin) adb.write_file("/data/rayhunter/rayhunter-daemon", rayhunter_daemon_bin)
.await?; .await?;
adb.write_file( adb.write_file(
+1 -1
View File
@@ -48,7 +48,7 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
) )
.await?; .await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
telnet_send_file( telnet_send_file(
addr, addr,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
+1 -1
View File
@@ -188,7 +188,7 @@ async fn tplink_run_install(
install_config(&mut conn, "tplink", reset_config).await?; install_config(&mut conn, "tplink", reset_config).await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
telnet_send_file( telnet_send_file(
addr, addr,
+1 -1
View File
@@ -148,7 +148,7 @@ async fn install_rayhunter_files(adb_device: &mut ADBUSBDevice) -> Result<()> {
adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?; adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?;
// Install rayhunter daemon binary with verification // Install rayhunter daemon binary with verification
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
install_file( install_file(
adb_device, adb_device,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
+1 -2
View File
@@ -26,7 +26,6 @@ pub async fn install(
Args { Args {
admin_ip, admin_ip,
admin_password, admin_password,
..
}: Args, }: Args,
) -> Result<()> { ) -> Result<()> {
wingtech_run_install(admin_ip, admin_password).await wingtech_run_install(admin_ip, admin_password).await
@@ -105,7 +104,7 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
) )
.await?; .await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
telnet_send_file( telnet_send_file(
addr, addr,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter" name = "rayhunter"
version = "0.10.2" version = "0.11.1"
edition = "2024" edition = "2024"
description = "Realtime cellular data decoding and analysis for IMSI catcher detection" description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rootshell" name = "rootshell"
version = "0.10.2" version = "0.11.1"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+3 -3
View File
@@ -52,10 +52,10 @@ build_wifi_tools() {
fi fi
if ! command -v arm-linux-musleabihf-gcc &> /dev/null; then if ! command -v arm-linux-musleabihf-gcc &> /dev/null; then
echo "Error: arm-linux-musleabihf-gcc not found." echo "Warning: Skipping building WiFi tools due to missing C crosscompiler."
echo "arm-linux-musleabihf-gcc not found."
echo "Install with: brew install FiloSottile/musl-cross/musl-cross" echo "Install with: brew install FiloSottile/musl-cross/musl-cross"
echo "(Required because the installer bundles wpa_supplicant, wpa_cli, and iw for orbic-family devices.)" return
exit 1
fi fi
echo "Building WiFi tools..." echo "Building WiFi tools..."
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "telcom-parser" name = "telcom-parser"
version = "0.10.2" version = "0.11.1"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html