Compare commits

...

17 Commits

Author SHA1 Message Date
Cooper Quintin afeda3875d fmt 2026-05-01 14:34:11 -07:00
Cooper Quintin ced4090be5 appease clippy 2026-05-01 13:43:26 -07:00
Cooper Quintin 1471bb6f0b cargo fmt 2026-05-01 12:34:38 -07:00
Cooper Quintin ebc0ddb6b3 first pass at false positive removal 2026-05-01 12:31:50 -07: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
45 changed files with 2379 additions and 3084 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:
- "*" - "*"
+7 -7
View File
@@ -141,13 +141,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 +173,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 +227,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'
+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
+17 -1
View File
@@ -4705,6 +4705,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -4723,12 +4724,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
] ]
@@ -4763,7 +4766,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
@@ -6627,6 +6630,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
+1 -1
View File
@@ -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
View File
@@ -50,6 +50,43 @@ pub struct Config {
pub firewall_restrict_outbound: bool, pub firewall_restrict_outbound: bool,
/// Vector containing additional wifi client firewall ports to open /// Vector containing additional wifi client firewall ports to open
pub firewall_allowed_ports: Option<Vec<u16>>, pub firewall_allowed_ports: Option<Vec<u16>>,
/// Optional WebDAV upload configuration. When unset, no upload worker runs.
pub webdav: Option<WebdavConfig>,
}
/// Configuration for uploading finished QMDL recordings to a WebDAV server.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct WebdavConfig {
/// WebDAV server base URL, e.g. "https://example.com/remote.php/files/untitaker/my-subfolder/"
pub url: String,
/// Optional username for HTTP Basic auth
pub username: Option<String>,
/// Optional password for HTTP Basic auth
pub password: Option<String>,
/// Timeout (in seconds) for each upload request
pub upload_timeout_secs: u64,
/// How often (in seconds) the worker scans for entries to upload
pub poll_interval_secs: u64,
/// Minimum age (in seconds) an entry must have before it becomes eligible for upload
pub min_age_secs: i64,
/// Delete the file locally after a successful upload
pub delete_on_upload: bool,
}
impl Default for WebdavConfig {
fn default() -> Self {
WebdavConfig {
url: String::new(),
username: None,
password: None,
upload_timeout_secs: 300,
poll_interval_secs: 3600,
min_age_secs: 86400,
delete_on_upload: false,
}
}
} }
impl Default for Config { impl Default for Config {
@@ -74,6 +111,7 @@ impl Default for Config {
dns_servers: None, dns_servers: None,
firewall_restrict_outbound: true, firewall_restrict_outbound: true,
firewall_allowed_ports: None, firewall_allowed_ports: None,
webdav: 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}")]
+1
View File
@@ -12,6 +12,7 @@ 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 -10
View File
@@ -12,6 +12,8 @@ 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 +29,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 +45,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 +216,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(),
@@ -296,6 +290,15 @@ async fn run_with_config(
); );
firewall::apply(&config).await; firewall::apply(&config).await;
if let Some(webdav_config) = config.webdav.clone() {
run_webdav_upload_worker(
&task_tracker,
shutdown_token.clone(),
qmdl_store_lock.clone(),
webdav_config.into(),
);
}
let state = Arc::new(ServerState { let state = Arc::new(ServerState {
config_path: args.config_path.clone(), config_path: args.config_path.clone(),
config, config,
+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">
@@ -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
+29 -29
View File
@@ -164,7 +164,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 +188,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 +202,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 +210,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 +221,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 +259,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 +303,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 +318,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 +337,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
@@ -346,7 +346,7 @@
</div> </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 +359,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 +402,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 +421,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 +449,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 +469,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 +489,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,7 +500,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 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Device Security</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">Device Security</h3>
<div class="flex items-center"> <div class="flex items-center">
@@ -508,7 +508,7 @@
id="firewall_restrict_outbound" id="firewall_restrict_outbound"
type="checkbox" type="checkbox"
bind:checked={config.firewall_restrict_outbound} bind:checked={config.firewall_restrict_outbound}
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="firewall_restrict_outbound" for="firewall_restrict_outbound"
@@ -548,7 +548,7 @@
: null; : null;
}} }}
placeholder="22, 80" 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" 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 TCP ports, e.g. 22, 80 Comma-separated TCP ports, e.g. 22, 80
@@ -557,7 +557,7 @@
{/if} {/if}
</div> </div>
<div class="border-t pt-4 mt-6"> <div class="border-t border-gray-200 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 +567,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 +579,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 +594,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 +609,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 +621,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 +633,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 +645,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 +656,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 +700,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
+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,
});
} }
} }
} }
+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
+25
View File
@@ -55,6 +55,31 @@ firewall_restrict_outbound = true
# Example: allow HTTP (80) and SSH (22). # Example: allow HTTP (80) and SSH (22).
# firewall_allowed_ports = [80, 22] # firewall_allowed_ports = [80, 22]
# WebDAV Upload
# If a [webdav] section is present, finished recordings (both the raw .qmdl file
# and its .ndjson analysis output) are uploaded in the background to a WebDAV
# server once they've been closed for at least min_age_secs. After a successful
# upload the entry is either marked as uploaded in the manifest, or deleted
# locally if delete_on_upload = true. With no [webdav] section, no upload
# worker runs.
#
# [webdav]
# host = "https://dav.example.com"
# remote_path = "/rayhunter"
# # HTTP Basic auth. Both fields are optional; a password without a username is
# # rejected and the request is sent unauthenticated.
# username = "user"
# password = "pass"
# # Timeout in seconds for each upload request (default 300).
# upload_timeout_secs = 300
# # How often the worker scans for eligible entries (default 3600).
# poll_interval_secs = 3600
# # Minimum age in seconds before an entry becomes eligible for upload
# # (default 86400 = 1 day).
# min_age_secs = 86400
# # Delete the entry locally after a successful upload (default false).
# delete_on_upload = false
# Analyzer Configuration # Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics # Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details # See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
+35
View File
@@ -55,4 +55,39 @@ You can also configure WiFi during installation:
- **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. - **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.
## WebDAV Upload
Rayhunter can automatically upload finished recordings to a WebDAV server. When a `[webdav]` section is present in `config.toml`, a background worker periodically scans the recording store and uploads any closed entry that is older than `min_age_secs`. Each eligible entry uploads two files: the raw `.qmdl` capture and its `.ndjson` analysis output. After a successful upload the entry is either marked as uploaded in the manifest (and skipped on subsequent polls), or deleted locally if `delete_on_upload = true`. With no `[webdav]` section, no upload worker runs.
WebDAV upload is currently configurable only by editing `config.toml` — there is no web UI control for it yet.
| Key | Required | Default | Description |
| --- | --- | --- | --- |
| `url` | yes | — | WebDAV server base URL, e.g. `https://example.com/remote.php/files/user/rayhunter/` |
| `username` | no | — | HTTP Basic auth username |
| `password` | no | — | HTTP Basic auth password |
| `upload_timeout_secs` | no | `300` | Timeout (seconds) for each upload request |
| `poll_interval_secs` | no | `3600` | How often (seconds) the worker scans for eligible entries |
| `min_age_secs` | no | `86400` | Minimum age (seconds) an entry must have before it becomes eligible for upload |
| `delete_on_upload` | no | `false` | Delete the entry locally after a successful upload |
Example:
```toml
[webdav]
url = "https://dav.example.com/rayhunter/"
username = "user"
password = "pass"
upload_timeout_secs = 300
poll_interval_secs = 3600
min_age_secs = 86400
delete_on_upload = false
```
A few notes on behavior:
- **Auth:** HTTP Basic. Supplying a `password` without a `username` is rejected — the request is sent unauthenticated and a warning is logged.
- **Retries and overwrites:** each entry's two files (`.qmdl` and `.ndjson`) must both upload successfully before the entry is marked as uploaded in the manifest. If one upload fails, the entry stays unmarked and both files are retried on the next poll — the one that previously succeeded will be overwritten on the server. Once an entry is marked as uploaded, Rayhunter will not upload it again.
- **Currently-recording entry:** the active recording is never uploaded; only closed entries are eligible.
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in). 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).
+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 -9
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(
+2 -8
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?;
+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",
+127 -9
View File
@@ -7,8 +7,13 @@ use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement}; use super::information_element::{InformationElement, LteInformationElement};
use log::debug; use log::debug;
use pycrate_rs::nas::generated::emm::emm_attach_reject::EMMCauseEMMCause as AttachRejectEMMCause;
use pycrate_rs::nas::generated::emm::emm_attach_request::TAI;
use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1};
use telcom_parser::lte_rrc::{MCC_MNC_Digit, PLMN_Identity, PLMN_IdentityList};
use telcom_parser::lte_rrc::{ use telcom_parser::lte_rrc::{
DL_DCCH_MessageType, DL_DCCH_MessageType_c1, UL_CCCH_MessageType, UL_CCCH_MessageType_c1, /* DL_DCCH_MessageType, DL_DCCH_MessageType_c1,*/ UL_CCCH_MessageType,
UL_CCCH_MessageType_c1,
}; };
const TIMEOUT_THRESHHOLD: usize = 50; const TIMEOUT_THRESHHOLD: usize = 50;
@@ -26,6 +31,8 @@ pub struct ImsiRequestedAnalyzer {
state: State, state: State,
timeout_counter: usize, timeout_counter: usize,
flag: Option<Event>, flag: Option<Event>,
likely_enb_plmn: String,
likely_ue_plmn: String,
} }
impl Default for ImsiRequestedAnalyzer { impl Default for ImsiRequestedAnalyzer {
@@ -40,6 +47,10 @@ impl ImsiRequestedAnalyzer {
state: State::Unattached, state: State::Unattached,
timeout_counter: 0, timeout_counter: 0,
flag: None, flag: None,
// You will likely wonder why this isn't an Option<PLMN{mcc: u32, mnc: u32}>
// The answer is that I like strings.
likely_enb_plmn: "Unknown".to_string(),
likely_ue_plmn: "Unknown".to_string(),
} }
} }
@@ -72,10 +83,20 @@ impl ImsiRequestedAnalyzer {
// IMSI to Disconnect without AuthAccept // IMSI to Disconnect without AuthAccept
(State::IdentityRequest, State::Disconnect) => { (State::IdentityRequest, State::Disconnect) => {
self.flag = Some(Event { if self.likely_enb_plmn == self.likely_ue_plmn {
event_type: EventType::High, self.flag = Some(Event {
message: "Disconnected after Identity Request without Auth Accept".to_string(), event_type: EventType::High,
}); message: "Disconnected after Identity Request without Auth Accept on home network!".to_string(),
});
} else {
self.flag = Some(Event {
event_type: EventType::Low,
message: format!(
"Disconnected after Identity Request without Auth Accept, but this could be a false positive roaming issue - Tower PLMN: {}, UE PLMN: {}",
self.likely_enb_plmn, self.likely_ue_plmn
),
});
}
} }
(_, State::IdentityRequest) => { (_, State::IdentityRequest) => {
@@ -92,6 +113,71 @@ impl ImsiRequestedAnalyzer {
} }
self.state = next_state; self.state = next_state;
} }
// Sometimes an ENB can have multiple PLMNS
fn format_plmn_list(&mut self, plmn_list: &PLMN_IdentityList) -> String {
plmn_list
.0
.iter()
.map(|info| self.plmn_identity_to_str(&info.plmn_identity))
.collect::<Vec<_>>()
.join(", ")
}
// PLMN is represented in two very different ways in the LTE spec so we need
// two very different functions to decode them. I hate this.
fn plmn_identity_to_str(&mut self, plmn: &PLMN_Identity) -> String {
let mcc_digits: String = plmn
.mcc
.as_ref()
.map(|mcc| {
mcc.0
.iter()
.map(|MCC_MNC_Digit(n)| n.to_string())
.collect::<String>()
})
.unwrap_or_default();
let mnc_digits: String = plmn
.mnc
.0
.iter()
.map(|MCC_MNC_Digit(n)| n.to_string())
.collect::<String>();
format!("{}-{}", mcc_digits, mnc_digits)
}
fn plmn_vec_to_str(&mut self, bytes: &[u8]) -> String {
let mcc_digit1 = bytes[0] & 0x0F;
let mcc_digit2 = (bytes[0] >> 4) & 0x0F;
let mcc_digit3 = bytes[1] & 0x0F;
let mnc_digit1 = bytes[2] & 0x0F;
let mnc_digit2 = (bytes[2] >> 4) & 0x0F;
let mnc_digit3 = (bytes[1] >> 4) & 0x0F;
let mcc = mcc_digit1 as u32 * 100 + mcc_digit2 as u32 * 10 + mcc_digit3 as u32;
let mcc_str = format!("{:03}", mcc);
let mnc_str = if mnc_digit3 == 0xF {
format!("{:02}", mnc_digit1 * 10 + mnc_digit2)
} else {
format!(
"{:03}",
mnc_digit1 as u32 * 100 + mnc_digit2 as u32 * 10 + mnc_digit3 as u32
)
};
format!("{}-{}", mcc_str, mnc_str)
}
fn extract_plmn(&mut self, old_tai: &Option<TAI>) -> String {
match old_tai {
Some(t) => self.plmn_vec_to_str(&t.plmn),
None => "Unknown".to_string(),
}
}
} }
impl Analyzer for ImsiRequestedAnalyzer { impl Analyzer for ImsiRequestedAnalyzer {
@@ -106,7 +192,7 @@ impl Analyzer for ImsiRequestedAnalyzer {
} }
fn get_version(&self) -> u32 { fn get_version(&self) -> u32 {
3 4
} }
fn analyze_information_element( fn analyze_information_element(
@@ -114,11 +200,29 @@ impl Analyzer for ImsiRequestedAnalyzer {
ie: &InformationElement, ie: &InformationElement,
packet_num: usize, packet_num: usize,
) -> Option<Event> { ) -> Option<Event> {
// Set the enodeb plmn to the last sib1 we got, we should improve this once we have PCI data, this
// is a naive approach.
if let InformationElement::LTE(lte_ie) = ie
&& let LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie
&& let BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message
&& let BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1
{
let plmn = &sib1.cell_access_related_info.plmn_identity_list;
self.likely_enb_plmn = self.format_plmn_list(plmn);
return None;
}
if let InformationElement::LTE(inner) = ie { if let InformationElement::LTE(inner) = ie {
match &**inner { match &**inner {
LteInformationElement::NAS(payload) => match payload { LteInformationElement::NAS(payload) => match payload {
NASMessage::EMMMessage(EMMMessage::EMMExtServiceRequest(_)) NASMessage::EMMMessage(EMMMessage::EMMAttachRequest(request)) => {
| NASMessage::EMMMessage(EMMMessage::EMMAttachRequest(_)) => { if self.likely_ue_plmn == "Unknown" {
self.likely_ue_plmn = self.extract_plmn(&request.old_tai.inner);
}
self.transition(State::AttachRequest, packet_num);
}
NASMessage::EMMMessage(EMMMessage::EMMExtServiceRequest(_)) => {
self.transition(State::AttachRequest, packet_num); self.transition(State::AttachRequest, packet_num);
} }
NASMessage::EMMMessage(EMMMessage::EMMIdentityRequest(_)) => { NASMessage::EMMMessage(EMMMessage::EMMIdentityRequest(_)) => {
@@ -129,12 +233,22 @@ impl Analyzer for ImsiRequestedAnalyzer {
self.transition(State::AuthAccept, packet_num); self.transition(State::AuthAccept, packet_num);
} }
NASMessage::EMMMessage(EMMMessage::EMMServiceReject(_)) NASMessage::EMMMessage(EMMMessage::EMMServiceReject(_))
| NASMessage::EMMMessage(EMMMessage::EMMAttachReject(_))
| NASMessage::EMMMessage(EMMMessage::EMMDetachRequestMO(_)) | NASMessage::EMMMessage(EMMMessage::EMMDetachRequestMO(_))
| NASMessage::EMMMessage(EMMMessage::EMMDetachRequestMT(_)) | NASMessage::EMMMessage(EMMMessage::EMMDetachRequestMT(_))
| NASMessage::EMMMessage(EMMMessage::EMMTrackingAreaUpdateReject(_)) => { | NASMessage::EMMMessage(EMMMessage::EMMTrackingAreaUpdateReject(_)) => {
self.transition(State::Disconnect, packet_num); self.transition(State::Disconnect, packet_num);
} }
NASMessage::EMMMessage(EMMMessage::EMMAttachReject(reject)) => {
self.transition(State::Disconnect, packet_num);
if reject.emm_cause.inner
== AttachRejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
{
self.flag = Some(Event {
event_type: EventType::Low,
message: "Identity requested without authentication but its likely a false positive unless your SIM card has an active plan".to_string(),
});
}
}
_ => {} _ => {}
}, },
@@ -148,6 +262,9 @@ impl Analyzer for ImsiRequestedAnalyzer {
_ => {} _ => {}
}, },
// This causes two messages in the event of a false positive when we should always get an attach reject anyway so
// I'm commentingit out until I figure out a smarter way to deal with it.
/*
LteInformationElement::DlDcch(rrc_payload) => { LteInformationElement::DlDcch(rrc_payload) => {
if let DL_DCCH_MessageType::C1(DL_DCCH_MessageType_c1::RrcConnectionRelease( if let DL_DCCH_MessageType::C1(DL_DCCH_MessageType_c1::RrcConnectionRelease(
_, _,
@@ -156,6 +273,7 @@ impl Analyzer for ImsiRequestedAnalyzer {
self.transition(State::Disconnect, packet_num) self.transition(State::Disconnect, packet_num)
} }
} }
*/
_ => {} _ => {}
} }
}; };
+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..."